From bdc9491e91770f05c5ccdee8f68a26dee893a0e0 Mon Sep 17 00:00:00 2001 From: Gabriele Righi Date: Thu, 15 Oct 2026 00:34:06 +0200 Subject: [PATCH 1/3] feat(settings): add theme preference with system default support #158 (AI-generated) --- App.tsx | 27 ++-- src/screens/SettingsScreen.tsx | 134 +++++++++++++++++- src/screens/__tests__/SettingsScreen.test.tsx | 23 +++ src/store/ThemeContext.tsx | 59 ++++++++ src/store/__tests__/ThemeContext.test.tsx | 127 +++++++++++++++++ src/utils/__tests__/useThemeColors.test.tsx | 52 +++++++ src/utils/i18n.ts | 25 ++++ src/utils/useThemeColors.ts | 19 ++- 8 files changed, 448 insertions(+), 18 deletions(-) create mode 100644 src/store/ThemeContext.tsx create mode 100644 src/store/__tests__/ThemeContext.test.tsx create mode 100644 src/utils/__tests__/useThemeColors.test.tsx diff --git a/App.tsx b/App.tsx index 78e7aa9..ee1a13b 100644 --- a/App.tsx +++ b/App.tsx @@ -18,6 +18,7 @@ SplashScreen.preventAutoHideAsync(); import { PeaksProvider } from './src/store/PeaksContext'; import { PlannerProvider } from './src/store/PlannerContext'; import { LocaleProvider, useLocale } from './src/store/LocaleContext'; +import { ThemeProvider } from './src/store/ThemeContext'; import { Colors } from './src/utils/theme'; import type { RootStackParamList, @@ -88,9 +89,7 @@ function BlocchiStack() { import { t } from './src/utils/i18n'; function TabNavigator() { - const scheme = useColorScheme(); - const isDark = scheme === 'dark'; - const colors = isDark ? Colors.dark : Colors.light; + const { colors } = useThemeColors(); return ( + {isReloading && ( - - - - - - - - + + + + + + + + + ); } diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 19d52cb..e8cde46 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -17,7 +17,7 @@ import * as Sharing from 'expo-sharing'; import { Platform } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useThemeColors } from '../utils/useThemeColors'; +import { useThemeColors, type ThemeMode } from '../utils/useThemeColors'; import { Spacing, FontSize, BorderRadius } from '../utils/theme'; import { t, SUPPORTED_LANGUAGES, getSupportedLocale } from '../utils/i18n'; import { useLocale } from '../store/LocaleContext'; @@ -25,17 +25,33 @@ import { usePeaks } from '../store/PeaksContext'; import { usePlanner } from '../store/PlannerContext'; import OnboardingModal from '../components/OnboardingModal'; +interface ThemeOption { + mode: ThemeMode; + labelKey: string; + icon: keyof typeof MaterialCommunityIcons.glyphMap; +} + +const THEME_OPTIONS: ThemeOption[] = [ + { mode: 'system', labelKey: 'settings.themeSystem', icon: 'theme-light-dark' }, + { mode: 'light', labelKey: 'settings.themeLight', icon: 'white-balance-sunny' }, + { mode: 'dark', labelKey: 'settings.themeDark', icon: 'weather-night' }, +]; + export default function SettingsScreen() { - const { colors } = useThemeColors(); + const { colors, themeMode = 'system', setThemeMode } = useThemeColors(); const [isImporting, setIsImporting] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false); const [showLanguageModal, setShowLanguageModal] = useState(false); + const [showThemeModal, setShowThemeModal] = useState(false); const { locale, changeLocale } = useLocale(); const { refreshData: refreshPeaks } = usePeaks(); const { refreshData: refreshPlanner } = usePlanner(); const activeLang = getSupportedLocale(locale); const currentLanguageItem = SUPPORTED_LANGUAGES.find((item) => item.code === activeLang) ?? SUPPORTED_LANGUAGES[0]; + const currentThemeOption = + THEME_OPTIONS.find((opt) => opt.mode === themeMode) ?? THEME_OPTIONS[0]; + const currentThemeLabel = t(currentThemeOption.labelKey); const changeLanguage = async (lang: string) => { await changeLocale(lang); @@ -179,6 +195,39 @@ export default function SettingsScreen() { + + + + + {t('settings.themeTitle')} + + + setShowThemeModal(true)} + accessibilityRole="button" + accessibilityLabel={`${t('settings.themeTitle')}: ${currentThemeLabel}`} + > + + {currentThemeLabel} + + + + + + setShowThemeModal(false)} + > + setShowThemeModal(false)} + > + e.stopPropagation()} + > + + + {t('settings.selectTheme')} + + setShowThemeModal(false)} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('common.cancel')} + > + + + + + + {THEME_OPTIONS.map((opt) => { + const isSelected = themeMode === opt.mode; + return ( + { + setShowThemeModal(false); + if (!isSelected && setThemeMode) { + await setThemeMode(opt.mode); + } + }} + > + + + + {t(opt.labelKey)} + + + {isSelected && ( + + )} + + ); + })} + + + + + setShowOnboarding(false)} /> ); diff --git a/src/screens/__tests__/SettingsScreen.test.tsx b/src/screens/__tests__/SettingsScreen.test.tsx index 8494776..d100bc3 100644 --- a/src/screens/__tests__/SettingsScreen.test.tsx +++ b/src/screens/__tests__/SettingsScreen.test.tsx @@ -27,6 +27,9 @@ jest.mock('expo-file-system/legacy', () => ({ documentDirectory: 'file:///test-doc-dir/', writeAsStringAsync: jest.fn(), readAsStringAsync: jest.fn(), + EncodingType: { + UTF8: 'utf8', + }, })); jest.mock('expo-sharing', () => ({ @@ -59,6 +62,7 @@ describe('SettingsScreen', () => { const mockChangeLocale = jest.fn(); const mockRefreshPeaks = jest.fn(); const mockRefreshPlanner = jest.fn(); + const mockSetThemeMode = jest.fn(); beforeEach(() => { jest.clearAllMocks(); @@ -66,6 +70,8 @@ describe('SettingsScreen', () => { mockUseThemeColors.mockReturnValue({ colors: Colors.light, isDark: false, + themeMode: 'system', + setThemeMode: mockSetThemeMode, }); mockUseLocale.mockReturnValue({ @@ -86,6 +92,7 @@ describe('SettingsScreen', () => { const { getByText } = await render(); expect(getByText(t('settings.langTitle'))).toBeTruthy(); + expect(getByText(t('settings.themeTitle'))).toBeTruthy(); expect(getByText(t('settings.backupTitle'))).toBeTruthy(); expect(getByText(t('settings.aboutTitle'))).toBeTruthy(); }); @@ -138,6 +145,22 @@ describe('SettingsScreen', () => { expect(queryByText(t('common.cancel'))).toBeNull(); }); + it('opens theme modal and allows selecting a theme mode', async () => { + const { getByText, queryByText } = await render(); + + const themeTile = getByText(t('settings.themeSystem')); + await fireEvent.press(themeTile); + + expect(getByText(t('settings.selectTheme'))).toBeTruthy(); + expect(getByText(t('settings.themeDark'))).toBeTruthy(); + expect(getByText(t('settings.themeLight'))).toBeTruthy(); + + await fireEvent.press(getByText(t('settings.themeDark'))); + + expect(mockSetThemeMode).toHaveBeenCalledWith('dark'); + expect(queryByText(t('settings.selectTheme'))).toBeNull(); + }); + it('opens and closes replay tutorial onboarding modal', async () => { const { getByText } = await render(); diff --git a/src/store/ThemeContext.tsx b/src/store/ThemeContext.tsx new file mode 100644 index 0000000..ab8323d --- /dev/null +++ b/src/store/ThemeContext.tsx @@ -0,0 +1,59 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { useColorScheme } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Colors } from '../utils/theme'; + +export type ThemeMode = 'system' | 'light' | 'dark'; +export type Palette = typeof Colors.light | typeof Colors.dark; + +export const THEME_STORAGE_KEY = '@climbstack/theme_mode'; + +export interface ThemeContextType { + themeMode: ThemeMode; + setThemeMode: (mode: ThemeMode) => Promise; + colors: Palette; + isDark: boolean; +} + +export const ThemeContext = createContext(null); + +export function useTheme(): ThemeContextType { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); + return ctx; +} + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [themeMode, setThemeModeState] = useState('system'); + const systemScheme = useColorScheme(); + + useEffect(() => { + AsyncStorage.getItem(THEME_STORAGE_KEY) + .then((stored) => { + if (stored === 'light' || stored === 'dark' || stored === 'system') { + setThemeModeState(stored); + } + }) + .catch((e) => { + console.error('Errore caricamento tema:', e); + }); + }, []); + + const setThemeMode = useCallback(async (mode: ThemeMode) => { + setThemeModeState(mode); + try { + await AsyncStorage.setItem(THEME_STORAGE_KEY, mode); + } catch (e) { + console.error('Errore salvataggio tema:', e); + } + }, []); + + const isDark = themeMode === 'system' ? systemScheme === 'dark' : themeMode === 'dark'; + const colors: Palette = isDark ? Colors.dark : Colors.light; + + return ( + + {children} + + ); +} diff --git a/src/store/__tests__/ThemeContext.test.tsx b/src/store/__tests__/ThemeContext.test.tsx new file mode 100644 index 0000000..2bca211 --- /dev/null +++ b/src/store/__tests__/ThemeContext.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { useColorScheme } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { ThemeProvider, useTheme, THEME_STORAGE_KEY } from '../ThemeContext'; +import { Colors } from '../../utils/theme'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +jest.mock('@react-native-async-storage/async-storage', () => + jest.requireActual('@react-native-async-storage/async-storage/jest/async-storage-mock') +); + +const mockUseColorScheme = useColorScheme as jest.MockedFunction; + +describe('ThemeContext', () => { + beforeEach(async () => { + await AsyncStorage.clear(); + jest.clearAllMocks(); + mockUseColorScheme.mockReturnValue('light'); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('throws error when useTheme is used outside ThemeProvider', async () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + await expect(renderHook(() => useTheme())).rejects.toThrow( + 'useTheme must be used within ThemeProvider' + ); + consoleError.mockRestore(); + }); + + it('defaults to system theme when no preference is saved', async () => { + mockUseColorScheme.mockReturnValue('light'); + const { result } = await renderHook(() => useTheme(), { wrapper }); + + expect(result.current.themeMode).toBe('system'); + expect(result.current.isDark).toBe(false); + expect(result.current.colors).toEqual(Colors.light); + }); + + it('respects system dark mode when themeMode is system', async () => { + mockUseColorScheme.mockReturnValue('dark'); + const { result } = await renderHook(() => useTheme(), { wrapper }); + + expect(result.current.themeMode).toBe('system'); + expect(result.current.isDark).toBe(true); + expect(result.current.colors).toEqual(Colors.dark); + }); + + it('loads saved theme preference from AsyncStorage on mount', async () => { + await AsyncStorage.setItem(THEME_STORAGE_KEY, 'dark'); + mockUseColorScheme.mockReturnValue('light'); + + const { result } = await renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.themeMode).toBe('dark'); + }); + expect(result.current.isDark).toBe(true); + expect(result.current.colors).toEqual(Colors.dark); + }); + + it('ignores invalid theme preference from AsyncStorage', async () => { + await AsyncStorage.setItem(THEME_STORAGE_KEY, 'invalid_mode'); + const { result } = await renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(result.current.themeMode).toBe('system'); + }); + }); + + it('handles error when loading theme fails', async () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(AsyncStorage, 'getItem').mockRejectedValueOnce(new Error('Storage failure')); + + const { result } = await renderHook(() => useTheme(), { wrapper }); + + await waitFor(() => { + expect(consoleError).toHaveBeenCalledWith('Errore caricamento tema:', expect.any(Error)); + }); + expect(result.current.themeMode).toBe('system'); + consoleError.mockRestore(); + }); + + it('updates and persists themeMode via setThemeMode', async () => { + const { result } = await renderHook(() => useTheme(), { wrapper }); + + await act(async () => { + await result.current.setThemeMode('dark'); + }); + + expect(result.current.themeMode).toBe('dark'); + expect(result.current.isDark).toBe(true); + expect(result.current.colors).toEqual(Colors.dark); + + const saved = await AsyncStorage.getItem(THEME_STORAGE_KEY); + expect(saved).toBe('dark'); + + await act(async () => { + await result.current.setThemeMode('light'); + }); + + expect(result.current.themeMode).toBe('light'); + expect(result.current.isDark).toBe(false); + expect(result.current.colors).toEqual(Colors.light); + + const savedLight = await AsyncStorage.getItem(THEME_STORAGE_KEY); + expect(savedLight).toBe('light'); + }); + + it('handles error when saving theme fails', async () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(AsyncStorage, 'setItem').mockRejectedValueOnce(new Error('Save failure')); + + const { result } = await renderHook(() => useTheme(), { wrapper }); + + await act(async () => { + await result.current.setThemeMode('dark'); + }); + + expect(consoleError).toHaveBeenCalledWith('Errore salvataggio tema:', expect.any(Error)); + consoleError.mockRestore(); + }); +}); diff --git a/src/utils/__tests__/useThemeColors.test.tsx b/src/utils/__tests__/useThemeColors.test.tsx new file mode 100644 index 0000000..211fef6 --- /dev/null +++ b/src/utils/__tests__/useThemeColors.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-native'; +import { useColorScheme } from 'react-native'; +import { useThemeColors } from '../useThemeColors'; +import { ThemeProvider } from '../../store/ThemeContext'; +import { Colors } from '../theme'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +jest.mock('@react-native-async-storage/async-storage', () => + jest.requireActual('@react-native-async-storage/async-storage/jest/async-storage-mock') +); + +const mockUseColorScheme = useColorScheme as jest.MockedFunction; + +describe('useThemeColors', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseColorScheme.mockReturnValue('light'); + }); + + it('falls back to system light scheme when used outside ThemeProvider', async () => { + mockUseColorScheme.mockReturnValue('light'); + const { result } = await renderHook(() => useThemeColors()); + + expect(result.current.isDark).toBe(false); + expect(result.current.colors).toEqual(Colors.light); + expect(result.current.themeMode).toBe('system'); + expect(typeof result.current.setThemeMode).toBe('function'); + }); + + it('falls back to system dark scheme when used outside ThemeProvider', async () => { + mockUseColorScheme.mockReturnValue('dark'); + const { result } = await renderHook(() => useThemeColors()); + + expect(result.current.isDark).toBe(true); + expect(result.current.colors).toEqual(Colors.dark); + expect(result.current.themeMode).toBe('system'); + }); + + it('uses ThemeContext values when wrapped in ThemeProvider', async () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = await renderHook(() => useThemeColors(), { wrapper }); + + expect(result.current.themeMode).toBe('system'); + expect(result.current.isDark).toBe(false); + expect(result.current.colors).toEqual(Colors.light); + }); +}); diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index a6e517f..6e9c2fe 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -206,6 +206,11 @@ const translations = { langTitle: 'Language', selectLanguage: 'Select Language', changingLanguage: 'Changing language...', + themeTitle: 'Theme', + selectTheme: 'Select Theme', + themeSystem: 'System Default', + themeLight: 'Light', + themeDark: 'Dark', backupTitle: 'Backup & Data', backupDesc: 'Export all your data (Peaks, Categories, Blocks) in JSON format. You can save it to Google Drive or email it to yourself as a safety backup.', @@ -431,6 +436,11 @@ const translations = { langTitle: 'Lingua', selectLanguage: 'Seleziona lingua', changingLanguage: 'Cambio lingua...', + themeTitle: 'Tema', + selectTheme: 'Seleziona tema', + themeSystem: 'Predefinito di sistema', + themeLight: 'Chiaro', + themeDark: 'Scuro', backupTitle: 'Backup & Dati', backupDesc: 'Esporta tutti i tuoi dati (Vette, Categorie, Blocchi) in formato JSON. Potrai salvarli su Google Drive o inviarteli per email come backup di sicurezza.', @@ -654,6 +664,11 @@ const translations = { langTitle: 'Sprache', selectLanguage: 'Sprache auswählen', changingLanguage: 'Sprache wird geändert...', + themeTitle: 'Design', + selectTheme: 'Design auswählen', + themeSystem: 'Systemstandard', + themeLight: 'Hell', + themeDark: 'Dunkel', backupTitle: 'Backup & Daten', backupDesc: 'Exportiere alle deine Daten (Gipfel, Kategorien, Blöcke) im JSON-Format. Du kannst sie auf Google Drive speichern oder dir als Sicherheitskopie per E-Mail senden.', @@ -879,6 +894,11 @@ const translations = { langTitle: 'Langue', selectLanguage: 'Sélectionner la langue', changingLanguage: 'Changement de langue...', + themeTitle: 'Thème', + selectTheme: 'Sélectionner le thème', + themeSystem: 'Par défaut du système', + themeLight: 'Clair', + themeDark: 'Sombre', backupTitle: 'Sauvegarde & Données', backupDesc: 'Exportez toutes vos données (Sommets, Catégories, Blocs) au format JSON. Vous pouvez les enregistrer sur Google Drive ou vous les envoyer par e-mail en tant que sauvegarde de sécurité.', @@ -1104,6 +1124,11 @@ const translations = { langTitle: 'Idioma', selectLanguage: 'Seleccionar idioma', changingLanguage: 'Cambiando idioma...', + themeTitle: 'Tema', + selectTheme: 'Seleccionar tema', + themeSystem: 'Predeterminado del sistema', + themeLight: 'Claro', + themeDark: 'Oscuro', backupTitle: 'Copia de seguridad y Datos', backupDesc: 'Exporta todos tus datos (Cimas, Categorías, Bloques) en formato JSON. Puedes guardarlo en Google Drive o enviártelo por correo como copia de seguridad.', diff --git a/src/utils/useThemeColors.ts b/src/utils/useThemeColors.ts index 4509eb4..a03db2c 100644 --- a/src/utils/useThemeColors.ts +++ b/src/utils/useThemeColors.ts @@ -1,9 +1,24 @@ +import { useContext } from 'react'; import { useColorScheme } from 'react-native'; import { Colors } from './theme'; +import { ThemeContext, type ThemeMode } from '../store/ThemeContext'; -/** Hook che ritorna la palette corretta in base al tema di sistema */ +export type { ThemeMode }; + +/** Hook che ritorna la palette corretta in base al tema selezionato o di sistema */ export function useThemeColors() { + const context = useContext(ThemeContext); const scheme = useColorScheme(); + + if (context) { + return context; + } + const isDark = scheme === 'dark'; - return { colors: isDark ? Colors.dark : Colors.light, isDark }; + return { + colors: isDark ? Colors.dark : Colors.light, + isDark, + themeMode: 'system' as ThemeMode, + setThemeMode: async () => {}, + }; } From 7755c9f5aba4d01b52af1a6a00fd81e10ea99402 Mon Sep 17 00:00:00 2001 From: Gabriele Righi Date: Thu, 15 Oct 2026 00:41:25 +0200 Subject: [PATCH 2/3] test(settings): add unit tests for theme modal edge cases #158 (AI-generated) --- src/screens/SettingsScreen.tsx | 4 + src/screens/__tests__/SettingsScreen.test.tsx | 91 ++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index e8cde46..64367e2 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -476,10 +476,12 @@ export default function SettingsScreen() { transparent animationType="fade" onRequestClose={() => setShowThemeModal(false)} + testID="theme-modal" > setShowThemeModal(false)} + testID="theme-modal-overlay" > e.stopPropagation()} + testID="theme-modal-content" > @@ -497,6 +500,7 @@ export default function SettingsScreen() { hitSlop={8} accessibilityRole="button" accessibilityLabel={t('common.cancel')} + testID="theme-modal-close" > diff --git a/src/screens/__tests__/SettingsScreen.test.tsx b/src/screens/__tests__/SettingsScreen.test.tsx index d100bc3..9bba762 100644 --- a/src/screens/__tests__/SettingsScreen.test.tsx +++ b/src/screens/__tests__/SettingsScreen.test.tsx @@ -161,12 +161,99 @@ describe('SettingsScreen', () => { expect(queryByText(t('settings.selectTheme'))).toBeNull(); }); - it('opens and closes replay tutorial onboarding modal', async () => { + it('closes theme modal via close button', async () => { + const { getByText, getByTestId, queryByTestId } = await render(); + + await fireEvent.press(getByText(t('settings.themeSystem'))); + expect(getByTestId('theme-modal')).toBeTruthy(); + + await fireEvent.press(getByTestId('theme-modal-close')); + expect(queryByTestId('theme-modal-close')).toBeNull(); + }); + + it('closes theme modal via overlay click', async () => { + const { getByText, getByTestId, queryByTestId } = await render(); + + await fireEvent.press(getByText(t('settings.themeSystem'))); + expect(getByTestId('theme-modal')).toBeTruthy(); + + await fireEvent.press(getByTestId('theme-modal-overlay')); + expect(queryByTestId('theme-modal-overlay')).toBeNull(); + }); + + it('stops propagation when clicking modal content', async () => { + const { getByText, getByTestId } = await render(); + + await fireEvent.press(getByText(t('settings.themeSystem'))); + const content = getByTestId('theme-modal-content'); + const stopPropagation = jest.fn(); + + await fireEvent.press(content, { stopPropagation }); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('handles onRequestClose on theme modal', async () => { + const { getByText, getByTestId, queryByTestId } = await render(); + + await fireEvent.press(getByText(t('settings.themeSystem'))); + const modal = getByTestId('theme-modal'); + + await fireEvent(modal, 'requestClose'); + expect(queryByTestId('theme-modal-close')).toBeNull(); + }); + + it('closes theme modal without calling setThemeMode if current theme is pressed', async () => { + const { getByText, getAllByText, queryByText } = await render(); + + await fireEvent.press(getByText(t('settings.themeSystem'))); + expect(getByText(t('settings.selectTheme'))).toBeTruthy(); + + const systemOptions = getAllByText(t('settings.themeSystem')); + await fireEvent.press(systemOptions[1]); + + expect(mockSetThemeMode).not.toHaveBeenCalled(); + expect(queryByText(t('settings.selectTheme'))).toBeNull(); + }); + + it('handles selecting theme when setThemeMode is undefined', async () => { + mockUseThemeColors.mockReturnValueOnce({ + colors: Colors.light, + isDark: false, + themeMode: 'system', + setThemeMode: undefined, + }); + + const { getByText, queryByText } = await render(); + + await fireEvent.press(getByText(t('settings.themeSystem'))); + expect(getByText(t('settings.selectTheme'))).toBeTruthy(); + + await fireEvent.press(getByText(t('settings.themeLight'))); + expect(queryByText(t('settings.selectTheme'))).toBeNull(); + }); + + it('falls back to default theme option when themeMode is unknown or missing', async () => { + mockUseThemeColors.mockReturnValueOnce({ + colors: Colors.light, + isDark: false, + themeMode: 'unknown-mode' as any, + setThemeMode: mockSetThemeMode, + }); + const { getByText } = await render(); + expect(getByText(t('settings.themeSystem'))).toBeTruthy(); + }); + + it('opens and closes replay tutorial onboarding modal', async () => { + const { getByText, queryByText } = await render(); const replayBtn = getByText(t('settings.replayTutorial')); await fireEvent.press(replayBtn); - expect(getByText(t('onboarding.skip'))).toBeTruthy(); + const skipBtn = getByText(t('onboarding.skip')); + expect(skipBtn).toBeTruthy(); + + await fireEvent.press(skipBtn); + expect(queryByText(t('onboarding.skip'))).toBeNull(); }); }); From b0ffa02c4cfec2491a82e2d4f34203874b0e38eb Mon Sep 17 00:00:00 2001 From: Gabriele Righi Date: Thu, 15 Oct 2026 00:52:18 +0200 Subject: [PATCH 3/3] test(settings): cover themeMode fallback and remove redundant destructuring default #158 (AI-generated) --- src/screens/SettingsScreen.tsx | 2 +- src/screens/__tests__/SettingsScreen.test.tsx | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 64367e2..5d8aba1 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -38,7 +38,7 @@ const THEME_OPTIONS: ThemeOption[] = [ ]; export default function SettingsScreen() { - const { colors, themeMode = 'system', setThemeMode } = useThemeColors(); + const { colors, themeMode, setThemeMode } = useThemeColors(); const [isImporting, setIsImporting] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false); const [showLanguageModal, setShowLanguageModal] = useState(false); diff --git a/src/screens/__tests__/SettingsScreen.test.tsx b/src/screens/__tests__/SettingsScreen.test.tsx index 9bba762..adc4fd0 100644 --- a/src/screens/__tests__/SettingsScreen.test.tsx +++ b/src/screens/__tests__/SettingsScreen.test.tsx @@ -244,6 +244,16 @@ describe('SettingsScreen', () => { expect(getByText(t('settings.themeSystem'))).toBeTruthy(); }); + it('renders correctly when themeMode and setThemeMode are not provided by useThemeColors', async () => { + mockUseThemeColors.mockReturnValueOnce({ + colors: Colors.light, + isDark: false, + }); + + const { getByText } = await render(); + expect(getByText(t('settings.themeSystem'))).toBeTruthy(); + }); + it('opens and closes replay tutorial onboarding modal', async () => { const { getByText, queryByText } = await render();