diff --git a/App.tsx b/App.tsx index efbcb62..a1d1e04 100644 --- a/App.tsx +++ b/App.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { NavigationContainer, LinkingOptions } from '@react-navigation/native'; import { AuthProvider } from './src/contexts/AuthContext'; +import { AccessibilityProvider } from './src/contexts/AccessibilityContext'; import { useAuth } from './src/hooks/useAuth'; import SplashScreen from './src/screens/SplashScreen'; import AuthNavigator from './src/navigation/AuthNavigator'; @@ -61,18 +62,20 @@ function RootNavigator() { export default function App() { return ( - - - linking={linkingConfig} - fallback={ - - - - } - > - - - + + + + linking={linkingConfig} + fallback={ + + + + } + > + + + + ); } diff --git a/jest.config.js b/jest.config.js index 6716ca1..1cdb148 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,5 +8,6 @@ module.exports = { 'node_modules/(?!(react-native|@react-native|@react-navigation)/)' ], testPathIgnorePatterns: ['/node_modules/', '/android/', '/ios/'], + setupFiles: ['/jest.setup.js'], resetMocks: true, }; \ No newline at end of file diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..5ede553 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,3 @@ +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock'), +); diff --git a/src/components/HorizontalMenu.tsx b/src/components/HorizontalMenu.tsx index 79248a7..658930a 100644 --- a/src/components/HorizontalMenu.tsx +++ b/src/components/HorizontalMenu.tsx @@ -1,6 +1,8 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native'; -import { Colors, Spacing, FontSize, BorderRadius } from '../theme'; +import { Spacing, FontSize, BorderRadius } from '../theme'; +import { ThemeColors } from '../theme/colors'; +import { useThemeColors, useFontScale } from '../contexts/AccessibilityContext'; interface HorizontalMenuProps { items: string[]; @@ -9,11 +11,16 @@ interface HorizontalMenuProps { } export default function HorizontalMenu({ items, selected, onSelect }: HorizontalMenuProps) { + const colors = useThemeColors(); + const fontScale = useFontScale(); + const styles = useStyles(colors, fontScale); + return ( {items.map((item) => { const isActive = item === selected; @@ -22,6 +29,10 @@ export default function HorizontalMenu({ items, selected, onSelect }: Horizontal key={item} style={[styles.item, isActive && styles.itemActive]} onPress={() => onSelect(item)} + accessibilityRole="tab" + accessibilityLabel={item} + accessibilityState={{ selected: isActive }} + accessibilityHint={`Filtrar por ${item}`} > {item} @@ -33,30 +44,36 @@ export default function HorizontalMenu({ items, selected, onSelect }: Horizontal ); } -const styles = StyleSheet.create({ - container: { - paddingHorizontal: Spacing.base, - paddingVertical: Spacing.sm, - gap: Spacing.sm, - }, - item: { - paddingHorizontal: Spacing.base, - paddingVertical: Spacing.sm, - borderRadius: BorderRadius.xl, - backgroundColor: Colors.surface, - borderWidth: 1, - borderColor: Colors.border, - }, - itemActive: { - backgroundColor: Colors.primary, - borderColor: Colors.primary, - }, - text: { - fontSize: FontSize.md, - color: Colors.textSecondary, - }, - textActive: { - color: Colors.textOnPrimary, - fontWeight: '600', - }, -}); +function useStyles(colors: ThemeColors, fontScale: number) { + return useMemo( + () => + StyleSheet.create({ + container: { + paddingHorizontal: Spacing.base, + paddingVertical: Spacing.sm, + gap: Spacing.sm, + }, + item: { + paddingHorizontal: Spacing.base, + paddingVertical: Spacing.sm, + borderRadius: BorderRadius.xl, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + itemActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + text: { + fontSize: FontSize.md * fontScale, + color: colors.textSecondary, + }, + textActive: { + color: colors.textOnPrimary, + fontWeight: '600', + }, + }), + [colors, fontScale], + ); +} diff --git a/src/contexts/AccessibilityContext.tsx b/src/contexts/AccessibilityContext.tsx new file mode 100644 index 0000000..c55f3bd --- /dev/null +++ b/src/contexts/AccessibilityContext.tsx @@ -0,0 +1,157 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, + useMemo, + ReactNode, +} from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Colors, HighContrastColors, ThemeColors } from '../theme/colors'; + +export type FontScale = 'normal' | 'grande' | 'maior'; + +// Multiplicador aplicado à tipografia conforme a preferência do usuário. +export const FONT_SCALE_FACTOR: Record = { + normal: 1, + grande: 1.2, + maior: 1.4, +}; + +const FONT_SCALE_LABEL: Record = { + normal: 'Normal', + grande: 'Grande', + maior: 'Maior', +}; + +const STORAGE_KEY_FONTE = '@App:acessibilidade:fontScale'; +const STORAGE_KEY_CONTRASTE = '@App:acessibilidade:highContrast'; + +const ORDEM_FONTE: FontScale[] = ['normal', 'grande', 'maior']; + +interface AccessibilityContextData { + fontScale: FontScale; + fontScaleFactor: number; + fontScaleLabel: string; + highContrast: boolean; + isLoading: boolean; + colors: ThemeColors; + setFontScale(escala: FontScale): Promise; + cycleFontScale(): Promise; + setHighContrast(ativo: boolean): Promise; + toggleHighContrast(): Promise; +} + +const AccessibilityContext = createContext( + {} as AccessibilityContextData, +); + +interface AccessibilityProviderProps { + children: ReactNode; +} + +export const AccessibilityProvider = ({ children }: AccessibilityProviderProps) => { + const [fontScale, setFontScaleState] = useState('normal'); + const [highContrast, setHighContrastState] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + async function carregarPreferencias() { + try { + const [fonteSalva, contrasteSalvo] = await Promise.all([ + AsyncStorage.getItem(STORAGE_KEY_FONTE), + AsyncStorage.getItem(STORAGE_KEY_CONTRASTE), + ]); + if (fonteSalva && fonteSalva in FONT_SCALE_FACTOR) { + setFontScaleState(fonteSalva as FontScale); + } + if (contrasteSalvo === 'true') { + setHighContrastState(true); + } + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + } + carregarPreferencias(); + }, []); + + const setFontScale = useCallback(async (escala: FontScale) => { + setFontScaleState(escala); + try { + await AsyncStorage.setItem(STORAGE_KEY_FONTE, escala); + } catch (error) { + console.error(error); + } + }, []); + + const cycleFontScale = useCallback(async () => { + const indiceAtual = ORDEM_FONTE.indexOf(fontScale); + const proxima = ORDEM_FONTE[(indiceAtual + 1) % ORDEM_FONTE.length]; + await setFontScale(proxima); + }, [fontScale, setFontScale]); + + const setHighContrast = useCallback(async (ativo: boolean) => { + setHighContrastState(ativo); + try { + await AsyncStorage.setItem(STORAGE_KEY_CONTRASTE, ativo ? 'true' : 'false'); + } catch (error) { + console.error(error); + } + }, []); + + const toggleHighContrast = useCallback(async () => { + await setHighContrast(!highContrast); + }, [highContrast, setHighContrast]); + + const value = useMemo( + () => ({ + fontScale, + fontScaleFactor: FONT_SCALE_FACTOR[fontScale], + fontScaleLabel: FONT_SCALE_LABEL[fontScale], + highContrast, + isLoading, + colors: highContrast ? HighContrastColors : Colors, + setFontScale, + cycleFontScale, + setHighContrast, + toggleHighContrast, + }), + [ + fontScale, + highContrast, + isLoading, + setFontScale, + cycleFontScale, + setHighContrast, + toggleHighContrast, + ], + ); + + return ( + + {children} + + ); +}; + +export function useAccessibility(): AccessibilityContextData { + return useContext(AccessibilityContext); +} + +// Retorna a paleta ativa conforme a preferência de contraste. +// Faz fallback para a paleta padrão fora do Provider. +export function useThemeColors(): ThemeColors { + const ctx = useContext(AccessibilityContext); + return ctx?.colors ?? Colors; +} + +// Retorna o multiplicador de fonte ativo, com fallback seguro (1). +export function useFontScale(): number { + const ctx = useContext(AccessibilityContext); + return ctx?.fontScaleFactor ?? 1; +} + +export default AccessibilityContext; diff --git a/src/contexts/__tests__/AccessibilityContext.test.tsx b/src/contexts/__tests__/AccessibilityContext.test.tsx new file mode 100644 index 0000000..4c21d21 --- /dev/null +++ b/src/contexts/__tests__/AccessibilityContext.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { act, render, screen, waitFor } from '@testing-library/react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + AccessibilityProvider, + useAccessibility, +} from '../AccessibilityContext'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), +})); + +const mockedAsyncStorage = AsyncStorage as jest.Mocked; + +let controle: ReturnType; + +function Sonda() { + controle = useAccessibility(); + return ( + + {controle.fontScaleLabel}-{controle.highContrast ? 'alto' : 'normal'} + + ); +} + +function renderizar() { + return render( + + + , + ); +} + +describe('AccessibilityContext', () => { + beforeEach(() => { + mockedAsyncStorage.getItem.mockResolvedValue(null); + mockedAsyncStorage.setItem.mockResolvedValue(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('usa os valores padrão quando não há nada persistido', async () => { + renderizar(); + + await waitFor(() => { + expect(screen.getByText('Normal-normal')).toBeTruthy(); + }); + expect(controle.fontScaleFactor).toBe(1); + }); + + it('persiste a escolha de alto contraste', async () => { + renderizar(); + await waitFor(() => expect(controle).toBeDefined()); + + await act(async () => { + await controle.setHighContrast(true); + }); + + expect(mockedAsyncStorage.setItem).toHaveBeenCalledWith( + '@App:acessibilidade:highContrast', + 'true', + ); + expect(controle.highContrast).toBe(true); + }); + + it('cicla o tamanho da fonte e persiste', async () => { + renderizar(); + await waitFor(() => expect(controle).toBeDefined()); + + await act(async () => { + await controle.cycleFontScale(); + }); + + expect(controle.fontScale).toBe('grande'); + expect(controle.fontScaleFactor).toBeGreaterThan(1); + expect(mockedAsyncStorage.setItem).toHaveBeenCalledWith( + '@App:acessibilidade:fontScale', + 'grande', + ); + }); + + it('carrega preferências previamente persistidas', async () => { + mockedAsyncStorage.getItem.mockImplementation(async (chave: string) => { + if (chave === '@App:acessibilidade:fontScale') return 'maior'; + if (chave === '@App:acessibilidade:highContrast') return 'true'; + return null; + }); + + renderizar(); + + await waitFor(() => { + expect(screen.getByText('Maior-alto')).toBeTruthy(); + }); + expect(controle.colors.background).toBeTruthy(); + }); +}); diff --git a/src/screens/app/ConfiguracoesScreen.tsx b/src/screens/app/ConfiguracoesScreen.tsx index c8df89e..896bd6d 100644 --- a/src/screens/app/ConfiguracoesScreen.tsx +++ b/src/screens/app/ConfiguracoesScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { View, Text, @@ -10,35 +10,90 @@ import { Alert, } from 'react-native'; import Ionicons from 'react-native-vector-icons/Ionicons'; +import { ThemeColors } from '../../theme/colors'; +import { useAccessibility, useThemeColors, useFontScale } from '../../contexts/AccessibilityContext'; -function SettingToggle({ label, value, onToggle }: { label: string; value: boolean; onToggle: (v: boolean) => void }) { +function SettingToggle({ + label, + value, + onToggle, + colors, + fontScale, + hint, +}: { + label: string; + value: boolean; + onToggle: (v: boolean) => void; + colors: ThemeColors; + fontScale: number; + hint?: string; +}) { + const styles = useStyles(colors, fontScale); return ( {label} ); } -function SettingOption({ icon, label, value, onPress }: { icon: string; label: string; value?: string; onPress: () => void }) { +function SettingOption({ + icon, + label, + value, + onPress, + colors, + fontScale, + hint, +}: { + icon: string; + label: string; + value?: string; + onPress: () => void; + colors: ThemeColors; + fontScale: number; + hint?: string; +}) { + const styles = useStyles(colors, fontScale); return ( - - + + {label} {value && {value}} - + ); } export default function ConfiguracoesScreen() { + const colors = useThemeColors(); + const fontScale = useFontScale(); + const styles = useStyles(colors, fontScale); + const { + fontScaleLabel, + cycleFontScale, + highContrast, + setHighContrast, + } = useAccessibility(); + const [lembretes, setLembretes] = useState(true); const [amigos, setAmigos] = useState(false); const [conquistas, setConquistas] = useState(true); @@ -71,80 +126,114 @@ export default function ConfiguracoesScreen() { - Notificações + + Notificações + - Aparência + + Aparência + - Vídeo e Armazenamento + + Vídeo e Armazenamento + - Acessibilidade + + Acessibilidade + {}} + value={fontScaleLabel} + onPress={cycleFontScale} + colors={colors} + fontScale={fontScale} + hint="Toque para alternar entre Normal, Grande e Maior" /> - {}} + value={highContrast} + onToggle={setHighContrast} + colors={colors} + fontScale={fontScale} + hint="Ativa cores de alto contraste para facilitar a leitura" /> - Sobre + + Sobre + {}} + colors={colors} + fontScale={fontScale} /> {}} + colors={colors} + fontScale={fontScale} /> {}} + colors={colors} + fontScale={fontScale} /> @@ -152,58 +241,65 @@ export default function ConfiguracoesScreen() { ); } -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#FFFFFF', - }, - content: { - paddingBottom: 40, - }, - secao: { - paddingHorizontal: 20, - paddingTop: 20, - }, - secaoTitulo: { - fontSize: 13, - fontWeight: '600', - color: '#7A869A', - textTransform: 'uppercase', - letterSpacing: 0.5, - marginBottom: 12, - }, - toggleRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: '#F0F2F5', - }, - toggleLabel: { - fontSize: 16, - color: '#1A1A1A', - }, - optionRow: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 14, - borderBottomWidth: 1, - borderBottomColor: '#F0F2F5', - }, - optionLabel: { - flex: 1, - fontSize: 16, - color: '#1A1A1A', - marginLeft: 12, - }, - optionRight: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - }, - optionValue: { - fontSize: 14, - color: '#7A869A', - }, -}); +function useStyles(colors: ThemeColors, fontScale: number) { + return useMemo( + () => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + paddingBottom: 40, + }, + secao: { + paddingHorizontal: 20, + paddingTop: 20, + }, + secaoTitulo: { + fontSize: 13 * fontScale, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 12, + }, + toggleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: colors.borderLight, + }, + toggleLabel: { + flex: 1, + fontSize: 16 * fontScale, + color: colors.text, + }, + optionRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: colors.borderLight, + }, + optionLabel: { + flex: 1, + fontSize: 16 * fontScale, + color: colors.text, + marginLeft: 12, + }, + optionRight: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + optionValue: { + fontSize: 14 * fontScale, + color: colors.textSecondary, + }, + }), + [colors, fontScale], + ); +} diff --git a/src/screens/app/HomeScreen.tsx b/src/screens/app/HomeScreen.tsx index 82b46d4..d4c121a 100644 --- a/src/screens/app/HomeScreen.tsx +++ b/src/screens/app/HomeScreen.tsx @@ -1,8 +1,10 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, FlatList } from 'react-native'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import Ionicons from 'react-native-vector-icons/Ionicons'; -import { Colors, Spacing, FontSize, BorderRadius } from '../../theme'; +import { Spacing, FontSize, BorderRadius } from '../../theme'; +import { ThemeColors } from '../../theme/colors'; +import { useThemeColors, useFontScale } from '../../contexts/AccessibilityContext'; import { auth } from '../../services/firebase'; import { getCurrentUserById } from '../../services/models/user'; import { getCourseModulesOverview, ModuleOverview } from '../../services/models/courseOverview'; @@ -12,21 +14,34 @@ interface CourseCardProps { progress: number; locked: boolean; onPress: () => void; + colors: ThemeColors; + fontScale: number; } -function CourseCard({ title, progress, locked, onPress }: CourseCardProps) { +function CourseCard({ title, progress, locked, onPress, colors, fontScale }: CourseCardProps) { const isCompleted = progress >= 100; + const styles = useStyles(colors, fontScale); + + const estado = locked + ? 'Bloqueado' + : isCompleted + ? 'Concluído' + : `${progress}% concluído`; return ( {title} {locked ? ( - + ) : ( {progress}% )} @@ -53,6 +68,9 @@ function CourseCard({ title, progress, locked, onPress }: CourseCardProps) { } export default function HomeScreen() { + const colors = useThemeColors(); + const fontScale = useFontScale(); + const styles = useStyles(colors, fontScale); const [modules, setModules] = useState([]); const [streak, setStreak] = useState(0); const navigation = useNavigation(); @@ -95,12 +113,18 @@ export default function HomeScreen() { return ( - - + + {streak} dias de ofensiva - Módulos + + Módulos + handleCardPress(item.id)} + colors={colors} + fontScale={fontScale} /> )} showsVerticalScrollIndicator={false} @@ -119,100 +145,106 @@ export default function HomeScreen() { ); } -const styles = StyleSheet.create({ - screenContainer: { - flex: 1, - backgroundColor: Colors.background, - padding: Spacing.lg, - }, - streakContainer: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: Spacing.base, - gap: Spacing.sm, - }, - streakText: { - fontSize: FontSize.base, - fontWeight: '600', - color: Colors.streak, - }, - title: { - fontSize: FontSize.title, - fontWeight: 'bold', - color: Colors.accent, - marginBottom: Spacing.lg, - }, - cardContainer: { - backgroundColor: Colors.card, - borderRadius: BorderRadius.xl, - borderWidth: 1.5, - borderColor: '#6379F2', - paddingVertical: Spacing.base, - paddingHorizontal: Spacing.lg, - marginBottom: Spacing.lg, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.05, - shadowRadius: 4, - elevation: 2, - }, - cardLocked: { - opacity: 0.5, - }, - headerRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: Spacing.sm, - }, - titleText: { - fontSize: FontSize.base, - color: Colors.textSecondary, - fontWeight: '500', - }, - percentageText: { - fontSize: FontSize.base, - color: Colors.textSecondary, - fontWeight: '500', - }, - progressBarTrack: { - height: 8, - backgroundColor: Colors.progressTrack, - borderRadius: BorderRadius.sm, - marginBottom: Spacing.base, - overflow: 'hidden', - }, - progressBarFill: { - height: '100%', - backgroundColor: Colors.progressFill, - borderRadius: BorderRadius.sm, - }, - footer: { - alignItems: 'center', - justifyContent: 'center', - }, - continueText: { - fontSize: FontSize.base, - fontWeight: 'bold', - color: Colors.primaryDark, - }, - lockedText: { - fontSize: FontSize.base, - color: Colors.textLight, - }, - completedContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - completedText: { - fontSize: FontSize.base, - fontWeight: 'bold', - color: Colors.primaryDark, - }, - checkIcon: { - fontSize: FontSize.lg, - fontWeight: 'bold', - color: Colors.success, - marginLeft: 6, - }, -}); +function useStyles(colors: ThemeColors, fontScale: number) { + return useMemo( + () => + StyleSheet.create({ + screenContainer: { + flex: 1, + backgroundColor: colors.background, + padding: Spacing.lg, + }, + streakContainer: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: Spacing.base, + gap: Spacing.sm, + }, + streakText: { + fontSize: FontSize.base * fontScale, + fontWeight: '600', + color: colors.streak, + }, + title: { + fontSize: FontSize.title * fontScale, + fontWeight: 'bold', + color: colors.accent, + marginBottom: Spacing.lg, + }, + cardContainer: { + backgroundColor: colors.card, + borderRadius: BorderRadius.xl, + borderWidth: 1.5, + borderColor: colors.borderAccent, + paddingVertical: Spacing.base, + paddingHorizontal: Spacing.lg, + marginBottom: Spacing.lg, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 2, + }, + cardLocked: { + opacity: 0.5, + }, + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: Spacing.sm, + }, + titleText: { + fontSize: FontSize.base * fontScale, + color: colors.textSecondary, + fontWeight: '500', + }, + percentageText: { + fontSize: FontSize.base * fontScale, + color: colors.textSecondary, + fontWeight: '500', + }, + progressBarTrack: { + height: 8, + backgroundColor: colors.progressTrack, + borderRadius: BorderRadius.sm, + marginBottom: Spacing.base, + overflow: 'hidden', + }, + progressBarFill: { + height: '100%', + backgroundColor: colors.progressFill, + borderRadius: BorderRadius.sm, + }, + footer: { + alignItems: 'center', + justifyContent: 'center', + }, + continueText: { + fontSize: FontSize.base * fontScale, + fontWeight: 'bold', + color: colors.primaryDark, + }, + lockedText: { + fontSize: FontSize.base * fontScale, + color: colors.textLight, + }, + completedContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + completedText: { + fontSize: FontSize.base * fontScale, + fontWeight: 'bold', + color: colors.primaryDark, + }, + checkIcon: { + fontSize: FontSize.lg * fontScale, + fontWeight: 'bold', + color: colors.success, + marginLeft: 6, + }, + }), + [colors, fontScale], + ); +} diff --git a/src/screens/app/NotificationScreen.tsx b/src/screens/app/NotificationScreen.tsx index 9c1972f..2ab2883 100644 --- a/src/screens/app/NotificationScreen.tsx +++ b/src/screens/app/NotificationScreen.tsx @@ -9,6 +9,8 @@ import { } from 'react-native'; import Ionicons from 'react-native-vector-icons/Ionicons'; import { Colors, Spacing, BorderRadius, FontSize } from '../../theme'; +import { ThemeColors } from '../../theme/colors'; +import { useThemeColors, useFontScale } from '../../contexts/AccessibilityContext'; type TipoNotificacao = 'lembrete' | 'conquista' | 'social' | 'sistema'; @@ -116,6 +118,8 @@ type CartaoProps = { onAcao: (item: Notificacao) => void; onMarcarLida: (id: string) => void; onExcluir: (id: string) => void; + colors: ThemeColors; + fontScale: number; }; function CartaoNotificacao({ @@ -124,8 +128,11 @@ function CartaoNotificacao({ onAcao, onMarcarLida, onExcluir, + colors, + fontScale, }: CartaoProps) { const config = CONFIG_TIPO[item.tipo]; + const styles = useStyles(colors, fontScale); return ( onLer(item.id)} onLongPress={() => onExcluir(item.id)} activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={`${item.lida ? 'Lida' : 'Não lida'}. ${item.titulo}. ${item.mensagem}. ${item.tempo}`} + accessibilityHint="Toque para marcar como lida. Pressione e segure para excluir." > @@ -152,7 +162,9 @@ function CartaoNotificacao({ {item.mensagem} - {!item.lida && } + {!item.lida && ( + + )} @@ -160,6 +172,8 @@ function CartaoNotificacao({ style={[styles.botaoAcaoPrincipal, { backgroundColor: config.cor + '1A' }]} onPress={() => onAcao(item)} activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={config.acaoLabel} > @@ -173,8 +187,10 @@ function CartaoNotificacao({ style={styles.botaoSecundario} onPress={() => onMarcarLida(item.id)} activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel="Marcar como lida" > - + Marcar como lida )} @@ -182,9 +198,11 @@ function CartaoNotificacao({ style={styles.botaoSecundario} onPress={() => onExcluir(item.id)} activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel="Excluir notificação" > - - + + Excluir @@ -195,6 +213,9 @@ function CartaoNotificacao({ } export default function NotificationScreen() { + const colors = useThemeColors(); + const fontScale = useFontScale(); + const styles = useStyles(colors, fontScale); const [filtro, setFiltro] = useState<(typeof FILTROS)[number]>('Todas'); const [notificacoes, setNotificacoes] = useState(buscarNotificacoesMock); @@ -242,7 +263,12 @@ export default function NotificationScreen() { {naoLidas > 0 && ( - + Marcar todas como lidas )} @@ -260,6 +286,9 @@ export default function NotificationScreen() { style={[styles.filtroChip, filtro === item && styles.filtroChipAtivo]} onPress={() => setFiltro(item)} activeOpacity={0.7} + accessibilityRole="tab" + accessibilityLabel={`Filtro ${item}`} + accessibilityState={{ selected: filtro === item }} > } ListEmptyComponent={ - + Nenhuma notificação } @@ -290,6 +319,8 @@ export default function NotificationScreen() { onAcao={executarAcao} onMarcarLida={marcarComoLida} onExcluir={excluir} + colors={colors} + fontScale={fontScale} /> )} /> @@ -297,157 +328,163 @@ export default function NotificationScreen() { ); } -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: Colors.background, - }, - headerActions: { - flexDirection: 'row', - justifyContent: 'flex-end', - paddingHorizontal: Spacing.base, - paddingTop: Spacing.sm, - }, - marcarTodas: { - fontSize: FontSize.sm, - color: Colors.primary, - fontWeight: '600', - }, - filtrosWrapper: { - borderBottomWidth: 1, - borderBottomColor: Colors.borderLight, - }, - filtros: { - paddingHorizontal: Spacing.base, - paddingVertical: Spacing.md, - gap: Spacing.sm, - }, - filtroChip: { - paddingHorizontal: Spacing.base, - paddingVertical: Spacing.sm, - borderRadius: BorderRadius.round, - backgroundColor: Colors.surface, - marginRight: Spacing.sm, - }, - filtroChipAtivo: { - backgroundColor: Colors.primary, - }, - filtroTexto: { - fontSize: FontSize.md, - color: Colors.textSecondary, - fontWeight: '500', - }, - filtroTextoAtivo: { - color: Colors.textOnPrimary, - }, - lista: { - padding: Spacing.base, - paddingBottom: Spacing.lg, - }, - separador: { - height: Spacing.md, - }, - notificacao: { - backgroundColor: Colors.card, - borderRadius: BorderRadius.lg, - borderWidth: 1, - borderColor: Colors.borderLight, - padding: Spacing.base, - }, - notificacaoNaoLida: { - backgroundColor: Colors.surface, - borderColor: Colors.borderAccent, - }, - linhaTopo: { - flexDirection: 'row', - alignItems: 'center', - }, - iconeContainer: { - width: 44, - height: 44, - borderRadius: BorderRadius.lg, - justifyContent: 'center', - alignItems: 'center', - }, - notificacaoInfo: { - flex: 1, - marginLeft: Spacing.md, - }, - notificacaoHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - notificacaoTitulo: { - flex: 1, - fontSize: FontSize.base, - fontWeight: '500', - color: Colors.text, - }, - textoNaoLido: { - fontWeight: '700', - }, - tempo: { - fontSize: FontSize.sm, - color: Colors.textLight, - marginLeft: Spacing.sm, - }, - mensagem: { - fontSize: FontSize.md, - color: Colors.textSecondary, - marginTop: Spacing.xs, - lineHeight: 18, - }, - indicadorNaoLido: { - width: 8, - height: 8, - borderRadius: BorderRadius.round, - backgroundColor: Colors.primary, - marginLeft: Spacing.sm, - }, - acoes: { - marginTop: Spacing.md, - paddingTop: Spacing.md, - borderTopWidth: 1, - borderTopColor: Colors.borderLight, - gap: Spacing.sm, - }, - botaoAcaoPrincipal: { - flexDirection: 'row', - alignItems: 'center', - alignSelf: 'flex-start', - gap: Spacing.xs, - paddingHorizontal: Spacing.md, - paddingVertical: Spacing.sm, - borderRadius: BorderRadius.md, - }, - botaoAcaoTexto: { - fontSize: FontSize.md, - fontWeight: '600', - }, - acoesSecundarias: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: Spacing.base, - }, - botaoSecundario: { - flexDirection: 'row', - alignItems: 'center', - gap: Spacing.xs, - paddingVertical: Spacing.xs, - }, - botaoSecundarioTexto: { - fontSize: FontSize.sm, - color: Colors.textSecondary, - fontWeight: '500', - }, - vazio: { - alignItems: 'center', - paddingTop: Spacing.xxxl + Spacing.lg, - }, - vazioTexto: { - fontSize: FontSize.base, - color: Colors.textSecondary, - marginTop: Spacing.md, - }, -}); +function useStyles(colors: ThemeColors, fontScale: number) { + return useMemo( + () => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + headerActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + paddingHorizontal: Spacing.base, + paddingTop: Spacing.sm, + }, + marcarTodas: { + fontSize: FontSize.sm * fontScale, + color: colors.primary, + fontWeight: '600', + }, + filtrosWrapper: { + borderBottomWidth: 1, + borderBottomColor: colors.borderLight, + }, + filtros: { + paddingHorizontal: Spacing.base, + paddingVertical: Spacing.md, + gap: Spacing.sm, + }, + filtroChip: { + paddingHorizontal: Spacing.base, + paddingVertical: Spacing.sm, + borderRadius: BorderRadius.round, + backgroundColor: colors.surface, + marginRight: Spacing.sm, + }, + filtroChipAtivo: { + backgroundColor: colors.primary, + }, + filtroTexto: { + fontSize: FontSize.md * fontScale, + color: colors.textSecondary, + fontWeight: '500', + }, + filtroTextoAtivo: { + color: colors.textOnPrimary, + }, + lista: { + padding: Spacing.base, + paddingBottom: Spacing.lg, + }, + separador: { + height: Spacing.md, + }, + notificacao: { + backgroundColor: colors.card, + borderRadius: BorderRadius.lg, + borderWidth: 1, + borderColor: colors.borderLight, + padding: Spacing.base, + }, + notificacaoNaoLida: { + backgroundColor: colors.surface, + borderColor: colors.borderAccent, + }, + linhaTopo: { + flexDirection: 'row', + alignItems: 'center', + }, + iconeContainer: { + width: 44, + height: 44, + borderRadius: BorderRadius.lg, + justifyContent: 'center', + alignItems: 'center', + }, + notificacaoInfo: { + flex: 1, + marginLeft: Spacing.md, + }, + notificacaoHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + notificacaoTitulo: { + flex: 1, + fontSize: FontSize.base * fontScale, + fontWeight: '500', + color: colors.text, + }, + textoNaoLido: { + fontWeight: '700', + }, + tempo: { + fontSize: FontSize.sm * fontScale, + color: colors.textLight, + marginLeft: Spacing.sm, + }, + mensagem: { + fontSize: FontSize.md * fontScale, + color: colors.textSecondary, + marginTop: Spacing.xs, + lineHeight: 18 * fontScale, + }, + indicadorNaoLido: { + width: 8, + height: 8, + borderRadius: BorderRadius.round, + backgroundColor: colors.primary, + marginLeft: Spacing.sm, + }, + acoes: { + marginTop: Spacing.md, + paddingTop: Spacing.md, + borderTopWidth: 1, + borderTopColor: colors.borderLight, + gap: Spacing.sm, + }, + botaoAcaoPrincipal: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + gap: Spacing.xs, + paddingHorizontal: Spacing.md, + paddingVertical: Spacing.sm, + borderRadius: BorderRadius.md, + }, + botaoAcaoTexto: { + fontSize: FontSize.md * fontScale, + fontWeight: '600', + }, + acoesSecundarias: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: Spacing.base, + }, + botaoSecundario: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xs, + paddingVertical: Spacing.xs, + }, + botaoSecundarioTexto: { + fontSize: FontSize.sm * fontScale, + color: colors.textSecondary, + fontWeight: '500', + }, + vazio: { + alignItems: 'center', + paddingTop: Spacing.xxxl + Spacing.lg, + }, + vazioTexto: { + fontSize: FontSize.base * fontScale, + color: colors.textSecondary, + marginTop: Spacing.md, + }, + }), + [colors, fontScale], + ); +} diff --git a/src/screens/app/PerfilScreen.tsx b/src/screens/app/PerfilScreen.tsx index 903dc89..7142437 100644 --- a/src/screens/app/PerfilScreen.tsx +++ b/src/screens/app/PerfilScreen.tsx @@ -1,15 +1,28 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Image } from 'react-native'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import Ionicons from 'react-native-vector-icons/Ionicons'; import { auth } from '../../services/firebase'; import { getCurrentUserById, UserProfile } from '../../services/models/user'; import { signOutUser } from '../../services/auth'; -import { Colors, Spacing, FontSize, BorderRadius } from '../../theme'; +import { Spacing, FontSize, BorderRadius } from '../../theme'; +import { ThemeColors } from '../../theme/colors'; +import { useThemeColors, useFontScale } from '../../contexts/AccessibilityContext'; -function ProfileInfoRow({ label, value }: { label: string; value: string }) { +function ProfileInfoRow({ + label, + value, + colors, + fontScale, +}: { + label: string; + value: string; + colors: ThemeColors; + fontScale: number; +}) { + const styles = useStyles(colors, fontScale); return ( - + {label} {value} @@ -17,6 +30,9 @@ function ProfileInfoRow({ label, value }: { label: string; value: string }) { } export default function PerfilScreen() { + const colors = useThemeColors(); + const fontScale = useFontScale(); + const styles = useStyles(colors, fontScale); const navigation = useNavigation(); const [profile, setProfile] = useState(null); @@ -44,63 +60,89 @@ export default function PerfilScreen() { {profile?.profilePictureUrl ? ( - + ) : ( - + )} - {name} + + {name} + {email} - + {profile?.xp ?? 0} XP Total - + {profile?.streak?.current ?? 0} Ofensiva - Informações pessoais - - - + + Informações pessoais + + + + navigation.navigate('EditProfile')} + accessibilityRole="button" + accessibilityLabel="Editar Perfil" + accessibilityHint="Abre a tela de edição do perfil" > - + Editar Perfil - + navigation.navigate('ChangePassword')} + accessibilityRole="button" + accessibilityLabel="Alterar Senha" + accessibilityHint="Abre a tela de alteração de senha" > - + Alterar Senha - + navigation.navigate('BugReport')} + accessibilityRole="button" + accessibilityLabel="Reportar Bug" + accessibilityHint="Abre a tela para reportar um problema" > - + Reportar Bug - + - - + + Sair @@ -108,123 +150,129 @@ export default function PerfilScreen() { ); } -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: Colors.background, - }, - content: { - padding: Spacing.lg, - }, - avatarSection: { - alignItems: 'center', - marginBottom: Spacing.xl, - }, - avatar: { - width: 96, - height: 96, - borderRadius: 48, - backgroundColor: Colors.primary, - justifyContent: 'center', - alignItems: 'center', - marginBottom: Spacing.md, - overflow: 'hidden', - }, - avatarImage: { - width: '100%', - height: '100%', - }, - name: { - fontSize: FontSize.xl, - fontWeight: 'bold', - color: Colors.text, - }, - email: { - fontSize: FontSize.md, - color: Colors.textSecondary, - marginTop: Spacing.xs, - }, - statsRow: { - flexDirection: 'row', - justifyContent: 'space-around', - backgroundColor: Colors.surface, - borderRadius: BorderRadius.lg, - padding: Spacing.base, - marginBottom: Spacing.xl, - }, - statBox: { - alignItems: 'center', - }, - statValue: { - fontSize: FontSize.xxl, - fontWeight: 'bold', - color: Colors.primary, - }, - statLabel: { - fontSize: FontSize.sm, - color: Colors.textSecondary, - marginTop: Spacing.xs, - }, - menuSection: { - gap: Spacing.sm, - }, - infoCard: { - backgroundColor: Colors.card, - borderRadius: BorderRadius.lg, - borderWidth: 1, - borderColor: Colors.border, - padding: Spacing.base, - marginBottom: Spacing.xl, - gap: Spacing.sm, - }, - infoTitle: { - fontSize: FontSize.base, - fontWeight: '600', - color: Colors.text, - marginBottom: Spacing.xs, - }, - infoRow: { - gap: Spacing.xs, - }, - infoLabel: { - fontSize: FontSize.sm, - color: Colors.textSecondary, - }, - infoValue: { - fontSize: FontSize.base, - color: Colors.text, - fontWeight: '500', - }, - menuItem: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: Colors.card, - borderRadius: BorderRadius.lg, - borderWidth: 1, - borderColor: Colors.border, - padding: Spacing.base, - gap: Spacing.md, - }, - menuText: { - flex: 1, - fontSize: FontSize.base, - color: Colors.text, - }, - logoutButton: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: Colors.card, - borderRadius: BorderRadius.lg, - borderWidth: 1, - borderColor: Colors.error, - padding: Spacing.base, - gap: Spacing.md, - marginTop: Spacing.md, - }, - logoutText: { - flex: 1, - fontSize: FontSize.base, - color: Colors.error, - fontWeight: '600', - }, -}); +function useStyles(colors: ThemeColors, fontScale: number) { + return useMemo( + () => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + padding: Spacing.lg, + }, + avatarSection: { + alignItems: 'center', + marginBottom: Spacing.xl, + }, + avatar: { + width: 96, + height: 96, + borderRadius: 48, + backgroundColor: colors.primary, + justifyContent: 'center', + alignItems: 'center', + marginBottom: Spacing.md, + overflow: 'hidden', + }, + avatarImage: { + width: '100%', + height: '100%', + }, + name: { + fontSize: FontSize.xl * fontScale, + fontWeight: 'bold', + color: colors.text, + }, + email: { + fontSize: FontSize.md * fontScale, + color: colors.textSecondary, + marginTop: Spacing.xs, + }, + statsRow: { + flexDirection: 'row', + justifyContent: 'space-around', + backgroundColor: colors.surface, + borderRadius: BorderRadius.lg, + padding: Spacing.base, + marginBottom: Spacing.xl, + }, + statBox: { + alignItems: 'center', + }, + statValue: { + fontSize: FontSize.xxl * fontScale, + fontWeight: 'bold', + color: colors.primary, + }, + statLabel: { + fontSize: FontSize.sm * fontScale, + color: colors.textSecondary, + marginTop: Spacing.xs, + }, + menuSection: { + gap: Spacing.sm, + }, + infoCard: { + backgroundColor: colors.card, + borderRadius: BorderRadius.lg, + borderWidth: 1, + borderColor: colors.border, + padding: Spacing.base, + marginBottom: Spacing.xl, + gap: Spacing.sm, + }, + infoTitle: { + fontSize: FontSize.base * fontScale, + fontWeight: '600', + color: colors.text, + marginBottom: Spacing.xs, + }, + infoRow: { + gap: Spacing.xs, + }, + infoLabel: { + fontSize: FontSize.sm * fontScale, + color: colors.textSecondary, + }, + infoValue: { + fontSize: FontSize.base * fontScale, + color: colors.text, + fontWeight: '500', + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.card, + borderRadius: BorderRadius.lg, + borderWidth: 1, + borderColor: colors.border, + padding: Spacing.base, + gap: Spacing.md, + }, + menuText: { + flex: 1, + fontSize: FontSize.base * fontScale, + color: colors.text, + }, + logoutButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.card, + borderRadius: BorderRadius.lg, + borderWidth: 1, + borderColor: colors.error, + padding: Spacing.base, + gap: Spacing.md, + marginTop: Spacing.md, + }, + logoutText: { + flex: 1, + fontSize: FontSize.base * fontScale, + color: colors.error, + fontWeight: '600', + }, + }), + [colors, fontScale], + ); +} diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index b48aaee..a2c1bf5 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { View, Text, @@ -14,7 +14,9 @@ import { import { AuthScreenProps } from '../../@types/navigation'; import { signInWithEmail } from '../../services/models/user'; import { isUserAuthenticated } from '../../services/auth'; -import { Colors, Spacing, FontSize, BorderRadius } from '../../theme'; +import { Spacing, FontSize, BorderRadius } from '../../theme'; +import { ThemeColors } from '../../theme/colors'; +import { useThemeColors, useFontScale } from '../../contexts/AccessibilityContext'; import Ionicons from 'react-native-vector-icons/Ionicons'; function getLoginErrorMessage(error: unknown) { @@ -36,6 +38,9 @@ function getLoginErrorMessage(error: unknown) { } export default function LoginScreen({ navigation }: AuthScreenProps<'Login'>) { + const colors = useThemeColors(); + const fontScale = useFontScale(); + const styles = useStyles(colors, fontScale); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); @@ -67,15 +72,25 @@ export default function LoginScreen({ navigation }: AuthScreenProps<'Login'>) { > - Login + + Login + {/* Social buttons */} - + Facebook - + Google @@ -92,11 +107,13 @@ export default function LoginScreen({ navigation }: AuthScreenProps<'Login'>) { {/* Password */} @@ -104,16 +121,23 @@ export default function LoginScreen({ navigation }: AuthScreenProps<'Login'>) { - setShowPassword(!showPassword)} style={styles.eyeIcon}> + setShowPassword(!showPassword)} + style={styles.eyeIcon} + accessibilityRole="button" + accessibilityLabel={showPassword ? 'Ocultar senha' : 'Mostrar senha'} + > @@ -122,6 +146,8 @@ export default function LoginScreen({ navigation }: AuthScreenProps<'Login'>) { navigation.navigate('ForgotPassword')} style={styles.forgotRow} + accessibilityRole="button" + accessibilityLabel="Esqueceu a senha?" > Esqueceu a senha? @@ -131,16 +157,23 @@ export default function LoginScreen({ navigation }: AuthScreenProps<'Login'>) { style={[styles.button, loading && styles.buttonDisabled]} onPress={handleLogin} disabled={loading} + accessibilityRole="button" + accessibilityLabel="Entrar" + accessibilityState={{ disabled: loading, busy: loading }} > {loading ? ( - + ) : ( Log In )} {/* Register link */} - navigation.navigate('Register')}> + navigation.navigate('Register')} + accessibilityRole="button" + accessibilityLabel="Não tem uma conta? Cadastre-se" + > Não tem uma conta?{' '} Cadastre-se @@ -152,112 +185,118 @@ export default function LoginScreen({ navigation }: AuthScreenProps<'Login'>) { ); } -const styles = StyleSheet.create({ - flex: { flex: 1, backgroundColor: Colors.background }, - scroll: { flexGrow: 1 }, - container: { - flex: 1, - paddingHorizontal: Spacing.xl, - paddingTop: Spacing.xxxl + Spacing.xl, - paddingBottom: Spacing.xxl, - }, - title: { - fontSize: FontSize.title, - fontWeight: 'bold', - color: Colors.accent, - marginBottom: 80, - }, - socialRow: { - flexDirection: 'row', - gap: Spacing.md, - marginBottom: Spacing.xl, - }, - socialButton: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: Spacing.sm, - backgroundColor: Colors.surface, - paddingVertical: Spacing.md, - borderRadius: BorderRadius.lg, - }, - socialText: { - fontSize: FontSize.base, - color: Colors.text, - fontWeight: '500', - }, - divider: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: Spacing.xl, - }, - dividerLine: { - flex: 1, - height: 1, - backgroundColor: Colors.border, - }, - dividerText: { - marginHorizontal: Spacing.md, - color: Colors.textSecondary, - fontSize: FontSize.base, - }, - input: { - backgroundColor: Colors.surface, - borderRadius: BorderRadius.lg, - paddingHorizontal: Spacing.base, - paddingVertical: Spacing.md, - fontSize: FontSize.base, - color: Colors.text, - marginBottom: Spacing.md, - height: 56, - }, - passwordContainer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: Colors.surface, - borderRadius: BorderRadius.lg, - height: 56, - paddingHorizontal: Spacing.base, - }, - passwordInput: { - flex: 1, - fontSize: FontSize.base, - color: Colors.text, - }, - eyeIcon: { - padding: Spacing.xs, - }, - forgotRow: { - alignSelf: 'flex-end', - marginTop: Spacing.sm, - marginBottom: Spacing.xl, - }, - forgotText: { - fontSize: FontSize.sm, - color: Colors.textSecondary, - }, - button: { - backgroundColor: Colors.accent, - borderRadius: 14, - height: 60, - alignItems: 'center', - justifyContent: 'center', - marginBottom: Spacing.xl, - }, - buttonDisabled: { opacity: 0.7 }, - buttonText: { - color: Colors.textOnPrimary, - fontSize: FontSize.base, - fontWeight: '700', - }, - registerText: { - textAlign: 'center', - fontSize: FontSize.base, - color: Colors.text, - }, - registerLink: { - color: Colors.accent, - fontWeight: '600', - }, -}); +function useStyles(colors: ThemeColors, fontScale: number) { + return useMemo( + () => + StyleSheet.create({ + flex: { flex: 1, backgroundColor: colors.background }, + scroll: { flexGrow: 1 }, + container: { + flex: 1, + paddingHorizontal: Spacing.xl, + paddingTop: Spacing.xxxl + Spacing.xl, + paddingBottom: Spacing.xxl, + }, + title: { + fontSize: FontSize.title * fontScale, + fontWeight: 'bold', + color: colors.accent, + marginBottom: 80, + }, + socialRow: { + flexDirection: 'row', + gap: Spacing.md, + marginBottom: Spacing.xl, + }, + socialButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.sm, + backgroundColor: colors.surface, + paddingVertical: Spacing.md, + borderRadius: BorderRadius.lg, + }, + socialText: { + fontSize: FontSize.base * fontScale, + color: colors.text, + fontWeight: '500', + }, + divider: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: Spacing.xl, + }, + dividerLine: { + flex: 1, + height: 1, + backgroundColor: colors.border, + }, + dividerText: { + marginHorizontal: Spacing.md, + color: colors.textSecondary, + fontSize: FontSize.base * fontScale, + }, + input: { + backgroundColor: colors.surface, + borderRadius: BorderRadius.lg, + paddingHorizontal: Spacing.base, + paddingVertical: Spacing.md, + fontSize: FontSize.base * fontScale, + color: colors.text, + marginBottom: Spacing.md, + height: 56, + }, + passwordContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.surface, + borderRadius: BorderRadius.lg, + height: 56, + paddingHorizontal: Spacing.base, + }, + passwordInput: { + flex: 1, + fontSize: FontSize.base * fontScale, + color: colors.text, + }, + eyeIcon: { + padding: Spacing.xs, + }, + forgotRow: { + alignSelf: 'flex-end', + marginTop: Spacing.sm, + marginBottom: Spacing.xl, + }, + forgotText: { + fontSize: FontSize.sm * fontScale, + color: colors.textSecondary, + }, + button: { + backgroundColor: colors.accent, + borderRadius: 14, + height: 60, + alignItems: 'center', + justifyContent: 'center', + marginBottom: Spacing.xl, + }, + buttonDisabled: { opacity: 0.7 }, + buttonText: { + color: colors.textOnPrimary, + fontSize: FontSize.base * fontScale, + fontWeight: '700', + }, + registerText: { + textAlign: 'center', + fontSize: FontSize.base * fontScale, + color: colors.text, + }, + registerLink: { + color: colors.accent, + fontWeight: '600', + }, + }), + [colors, fontScale], + ); +} diff --git a/src/screens/auth/RegisterScreen.tsx b/src/screens/auth/RegisterScreen.tsx index 9c1a285..8315cee 100644 --- a/src/screens/auth/RegisterScreen.tsx +++ b/src/screens/auth/RegisterScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { View, Text, @@ -15,6 +15,8 @@ import { AuthScreenProps } from '../../@types/navigation'; import { registerUserWithEmail } from '../../services/models/user'; import { validatePasswordStrength, PasswordStrength } from '../../services/auth'; import { Colors, Spacing, FontSize, BorderRadius } from '../../theme'; +import { ThemeColors } from '../../theme/colors'; +import { useThemeColors, useFontScale } from '../../contexts/AccessibilityContext'; import Ionicons from 'react-native-vector-icons/Ionicons'; const FORCA_CONFIG: Record = { @@ -24,6 +26,9 @@ const FORCA_CONFIG: Record) { + const colors = useThemeColors(); + const fontScale = useFontScale(); + const styles = useStyles(colors, fontScale); const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -81,15 +86,25 @@ export default function RegisterScreen({ navigation }: AuthScreenProps<'Register > - Cadastro + + Cadastro + {/* Social buttons */} - + Facebook - + Google @@ -106,34 +121,45 @@ export default function RegisterScreen({ navigation }: AuthScreenProps<'Register - setShowPassword(!showPassword)} style={styles.eyeIcon}> + setShowPassword(!showPassword)} + style={styles.eyeIcon} + accessibilityRole="button" + accessibilityLabel={showPassword ? 'Ocultar senha' : 'Mostrar senha'} + > @@ -169,9 +195,12 @@ export default function RegisterScreen({ navigation }: AuthScreenProps<'Register style={styles.termsRow} onPress={() => setAcceptedTerms(!acceptedTerms)} activeOpacity={0.7} + accessibilityRole="checkbox" + accessibilityLabel="Eu concordo com os Termos de Serviço e Política de Privacidade" + accessibilityState={{ checked: acceptedTerms }} > - {acceptedTerms && } + {acceptedTerms && } Eu concordo com os{' '} @@ -186,16 +215,23 @@ export default function RegisterScreen({ navigation }: AuthScreenProps<'Register style={[styles.button, loading && styles.buttonDisabled]} onPress={handleRegister} disabled={loading} + accessibilityRole="button" + accessibilityLabel="Criar Conta" + accessibilityState={{ disabled: loading, busy: loading }} > {loading ? ( - + ) : ( Criar Conta )} {/* Login link */} - navigation.navigate('Login')}> + navigation.navigate('Login')} + accessibilityRole="button" + accessibilityLabel="Já tem uma conta? Entrar" + > Já tem uma conta?{' '} Entrar @@ -207,162 +243,168 @@ export default function RegisterScreen({ navigation }: AuthScreenProps<'Register ); } -const styles = StyleSheet.create({ - flex: { flex: 1, backgroundColor: Colors.background }, - scroll: { flexGrow: 1 }, - container: { - flex: 1, - paddingHorizontal: Spacing.xl, - paddingTop: Spacing.xxxl + Spacing.xl, - paddingBottom: Spacing.xxl, - }, - title: { - fontSize: FontSize.title, - fontWeight: 'bold', - color: Colors.accent, - marginBottom: Spacing.xxxl, - }, - socialRow: { - flexDirection: 'row', - gap: Spacing.md, - marginBottom: Spacing.xl, - }, - socialButton: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: Spacing.sm, - backgroundColor: Colors.surface, - paddingVertical: Spacing.md, - borderRadius: BorderRadius.lg, - }, - socialText: { - fontSize: FontSize.base, - color: Colors.text, - fontWeight: '500', - }, - divider: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: Spacing.xl, - }, - dividerLine: { - flex: 1, - height: 1, - backgroundColor: Colors.border, - }, - dividerText: { - marginHorizontal: Spacing.md, - color: Colors.textSecondary, - fontSize: FontSize.base, - }, - input: { - backgroundColor: Colors.surface, - borderRadius: BorderRadius.lg, - paddingHorizontal: Spacing.base, - paddingVertical: Spacing.md, - fontSize: FontSize.base, - color: Colors.text, - marginBottom: Spacing.md, - height: 56, - }, - passwordContainer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: Colors.surface, - borderRadius: BorderRadius.lg, - marginBottom: Spacing.base, - height: 56, - paddingHorizontal: Spacing.base, - }, - passwordInput: { - flex: 1, - fontSize: FontSize.base, - color: Colors.text, - }, - eyeIcon: { - padding: Spacing.xs, - }, - strengthContainer: { - marginBottom: Spacing.base, - }, - strengthBarTrack: { - height: 6, - borderRadius: BorderRadius.round, - backgroundColor: Colors.progressTrack, - overflow: 'hidden', - }, - strengthBarFill: { - height: '100%', - borderRadius: BorderRadius.round, - }, - strengthLabel: { - marginTop: Spacing.xs, - fontSize: FontSize.sm, - fontWeight: '600', - }, - strengthHints: { - marginTop: Spacing.xs, - gap: 2, - }, - strengthHint: { - fontSize: FontSize.sm, - color: Colors.textSecondary, - lineHeight: 18, - }, - termsRow: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: Spacing.md, - marginBottom: Spacing.xl, - }, - checkbox: { - width: 22, - height: 22, - borderRadius: BorderRadius.sm, - borderWidth: 1.5, - borderColor: Colors.border, - alignItems: 'center', - justifyContent: 'center', - marginTop: 2, - flexShrink: 0, - }, - checkboxChecked: { - backgroundColor: Colors.accent, - borderColor: Colors.accent, - }, - termsText: { - flex: 1, - fontSize: FontSize.sm, - color: Colors.text, - lineHeight: 20, - }, - termsLink: { - color: Colors.accent, - fontWeight: '500', - }, - button: { - backgroundColor: Colors.accent, - borderRadius: 14, - height: 60, - alignItems: 'center', - justifyContent: 'center', - marginBottom: Spacing.xl, - }, - buttonDisabled: { opacity: 0.7 }, - buttonText: { - color: Colors.textOnPrimary, - fontSize: FontSize.base, - fontWeight: '700', - }, - loginText: { - textAlign: 'left', - fontSize: FontSize.base, - color: Colors.text, - }, - loginLink: { - color: Colors.accent, - fontWeight: '600', - }, -}); +function useStyles(colors: ThemeColors, fontScale: number) { + return useMemo( + () => + StyleSheet.create({ + flex: { flex: 1, backgroundColor: colors.background }, + scroll: { flexGrow: 1 }, + container: { + flex: 1, + paddingHorizontal: Spacing.xl, + paddingTop: Spacing.xxxl + Spacing.xl, + paddingBottom: Spacing.xxl, + }, + title: { + fontSize: FontSize.title * fontScale, + fontWeight: 'bold', + color: colors.accent, + marginBottom: Spacing.xxxl, + }, + socialRow: { + flexDirection: 'row', + gap: Spacing.md, + marginBottom: Spacing.xl, + }, + socialButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.sm, + backgroundColor: colors.surface, + paddingVertical: Spacing.md, + borderRadius: BorderRadius.lg, + }, + socialText: { + fontSize: FontSize.base * fontScale, + color: colors.text, + fontWeight: '500', + }, + divider: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: Spacing.xl, + }, + dividerLine: { + flex: 1, + height: 1, + backgroundColor: colors.border, + }, + dividerText: { + marginHorizontal: Spacing.md, + color: colors.textSecondary, + fontSize: FontSize.base * fontScale, + }, + input: { + backgroundColor: colors.surface, + borderRadius: BorderRadius.lg, + paddingHorizontal: Spacing.base, + paddingVertical: Spacing.md, + fontSize: FontSize.base * fontScale, + color: colors.text, + marginBottom: Spacing.md, + height: 56, + }, + passwordContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.surface, + borderRadius: BorderRadius.lg, + marginBottom: Spacing.base, + height: 56, + paddingHorizontal: Spacing.base, + }, + passwordInput: { + flex: 1, + fontSize: FontSize.base * fontScale, + color: colors.text, + }, + eyeIcon: { + padding: Spacing.xs, + }, + strengthContainer: { + marginBottom: Spacing.base, + }, + strengthBarTrack: { + height: 6, + borderRadius: BorderRadius.round, + backgroundColor: colors.progressTrack, + overflow: 'hidden', + }, + strengthBarFill: { + height: '100%', + borderRadius: BorderRadius.round, + }, + strengthLabel: { + marginTop: Spacing.xs, + fontSize: FontSize.sm * fontScale, + fontWeight: '600', + }, + strengthHints: { + marginTop: Spacing.xs, + gap: 2, + }, + strengthHint: { + fontSize: FontSize.sm * fontScale, + color: colors.textSecondary, + lineHeight: 18 * fontScale, + }, + termsRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: Spacing.md, + marginBottom: Spacing.xl, + }, + checkbox: { + width: 22, + height: 22, + borderRadius: BorderRadius.sm, + borderWidth: 1.5, + borderColor: colors.border, + alignItems: 'center', + justifyContent: 'center', + marginTop: 2, + flexShrink: 0, + }, + checkboxChecked: { + backgroundColor: colors.accent, + borderColor: colors.accent, + }, + termsText: { + flex: 1, + fontSize: FontSize.sm * fontScale, + color: colors.text, + lineHeight: 20 * fontScale, + }, + termsLink: { + color: colors.accent, + fontWeight: '500', + }, + button: { + backgroundColor: colors.accent, + borderRadius: 14, + height: 60, + alignItems: 'center', + justifyContent: 'center', + marginBottom: Spacing.xl, + }, + buttonDisabled: { opacity: 0.7 }, + buttonText: { + color: colors.textOnPrimary, + fontSize: FontSize.base * fontScale, + fontWeight: '700', + }, + loginText: { + textAlign: 'left', + fontSize: FontSize.base * fontScale, + color: colors.text, + }, + loginLink: { + color: colors.accent, + fontWeight: '600', + }, + }), + [colors, fontScale], + ); +} diff --git a/src/theme/colors.ts b/src/theme/colors.ts index 9b9d6d2..016a56b 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -29,3 +29,40 @@ export const Colors = { progressTrack: '#F0F2F5', progressFill: '#2D4CC8', } as const; + +export type ThemeColors = { [K in keyof typeof Colors]: string }; + +// Paleta de alto contraste (WCAG 2.2 AA). +// Fundo claro com textos quase pretos e elementos saturados escuros, +// garantindo razão de contraste alta para baixa visão. +export const HighContrastColors: ThemeColors = { + primary: '#3A00B8', + primaryDark: '#1A237E', + accent: '#0B3CC1', + error: '#B00020', + success: '#0B6E2E', + warning: '#8A4B00', + + background: '#FFFFFF', + surface: '#F2F2F2', + card: '#FFFFFF', + + text: '#000000', + textSecondary: '#1F1F1F', + textLight: '#3D3D3D', + textOnPrimary: '#FFFFFF', + + border: '#000000', + borderLight: '#5C5C5C', + borderAccent: '#0B3CC1', + + tabActive: '#3A00B8', + tabInactive: '#3D3D3D', + + podium: '#3A00B8', + gold: '#7A5C00', + streak: '#A33A00', + + progressTrack: '#D6D6D6', + progressFill: '#1A237E', +};