From 382d65e4cd0009141b2fa60d7854a6cacf64651e Mon Sep 17 00:00:00 2001 From: Pedro Nascimento Date: Mon, 22 Jun 2026 05:04:13 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20ler=20conte=C3=BAdo=20de=20m=C3=B3dulos?= =?UTF-8?q?/li=C3=A7=C3=B5es=20do=20Firestore=20com=20fallback=20offline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migra ModuleDetailScreen e SignTeachingScreen para Firestore-first, caindo em modules.ts quando a query vem vazia ou falha. Centraliza a estratégia em courseContent.ts e estende o schema de módulos/lições. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01V1X81giG6TqvkjJoyQJK1Y --- src/data/modules.ts | 3 + src/screens/app/ModuleDetailScreen.tsx | 28 ++- src/screens/app/SignTeachingScreen.tsx | 33 +++- .../app/__tests__/ModuleDetailScreen.test.tsx | 18 ++ .../app/__tests__/SignTeachingScreen.test.tsx | 40 +++- src/scripts/seedLessons.ts | 18 ++ .../services/models/courseContent.test.ts | 90 +++++++++ src/services/models/courseContent.ts | 173 ++++++++++++++++++ src/services/models/courseOverview.ts | 7 +- src/services/models/courses.ts | 3 + src/services/models/lessons.ts | 21 +++ 11 files changed, 419 insertions(+), 15 deletions(-) create mode 100644 src/services/__tests__/services/models/courseContent.test.ts create mode 100644 src/services/models/courseContent.ts diff --git a/src/data/modules.ts b/src/data/modules.ts index 98a47aa..1d33146 100644 --- a/src/data/modules.ts +++ b/src/data/modules.ts @@ -1,5 +1,8 @@ import { Module } from './types'; +// TODO: este arquivo agora é apenas FALLBACK offline. O conteúdo oficial vem do +// Firestore (ver src/services/models/courseContent.ts). A aposentadoria final +// deste arquivo acontece após o seed no Firebase real (rodar seedLessons.ts). export const MODULES: Module[] = [ { id: 'mod-1', diff --git a/src/screens/app/ModuleDetailScreen.tsx b/src/screens/app/ModuleDetailScreen.tsx index 288a280..4065692 100644 --- a/src/screens/app/ModuleDetailScreen.tsx +++ b/src/screens/app/ModuleDetailScreen.tsx @@ -1,20 +1,34 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { View, Text, StyleSheet, FlatList, TouchableOpacity } from 'react-native'; import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native'; import Ionicons from 'react-native-vector-icons/Ionicons'; -import { MODULES } from '../../data/modules'; import { Colors, Spacing, FontSize, BorderRadius } from '../../theme'; import type { AppStackScreenProps } from '../../@types/navigation'; import { auth } from '../../services/firebase'; import { getModuleLessonProgress } from '../../services/models/userProgress'; +import { getModuleDetail, type ModuleDetail } from '../../services/models/courseContent'; export default function ModuleDetailScreen() { const route = useRoute['route']>(); const navigation = useNavigation(); const { moduleId } = route.params; const [lessonStatus, setLessonStatus] = useState>({}); + const [mod, setMod] = useState(undefined); - const mod = MODULES.find((m) => m.id === moduleId); + useEffect(() => { + let active = true; + getModuleDetail(moduleId) + .then((detail) => { + if (active) setMod(detail ?? null); + }) + .catch((error) => { + console.error(error); + if (active) setMod(null); + }); + return () => { + active = false; + }; + }, [moduleId]); useFocusEffect( useCallback(() => { @@ -38,6 +52,14 @@ export default function ModuleDetailScreen() { }, [moduleId]) ); + if (mod === undefined) { + return ( + + Carregando módulo... + + ); + } + if (!mod) { return ( diff --git a/src/screens/app/SignTeachingScreen.tsx b/src/screens/app/SignTeachingScreen.tsx index 9843c08..d95442b 100644 --- a/src/screens/app/SignTeachingScreen.tsx +++ b/src/screens/app/SignTeachingScreen.tsx @@ -1,19 +1,42 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native'; import { useRoute, useNavigation } from '@react-navigation/native'; import Ionicons from 'react-native-vector-icons/Ionicons'; -import { MODULES } from '../../data/modules'; import { Colors, Spacing, FontSize, BorderRadius } from '../../theme'; import type { AppStackScreenProps } from '../../@types/navigation'; +import { getLessonContent, type LessonContent } from '../../services/models/courseContent'; export default function SignTeachingScreen() { const route = useRoute['route']>(); const navigation = useNavigation(); const { lessonId, moduleId } = route.params; const [isPlaying, setIsPlaying] = useState(false); + const [lesson, setLesson] = useState(undefined); - const mod = MODULES.find((m) => m.id === moduleId); - const lesson = mod?.lessons.find((l) => l.id === lessonId); + useEffect(() => { + let active = true; + getLessonContent(lessonId, moduleId) + .then((content) => { + if (active) setLesson(content ?? null); + }) + .catch((error) => { + console.error(error); + if (active) setLesson(null); + }); + return () => { + active = false; + }; + }, [lessonId, moduleId]); + + if (lesson === undefined) { + return ( + + + Carregando lição... + + + ); + } if (!lesson) { return ( @@ -26,7 +49,7 @@ export default function SignTeachingScreen() { const currentVocab = lesson.vocabulary[0]; const handleContinue = () => { - const questions = lesson.activities.flatMap((activity) => activity.questions); + const questions = lesson.questions; if (questions.length === 0) { navigation.navigate('LessonComplete', { diff --git a/src/screens/app/__tests__/ModuleDetailScreen.test.tsx b/src/screens/app/__tests__/ModuleDetailScreen.test.tsx index e85b74c..3e0852d 100644 --- a/src/screens/app/__tests__/ModuleDetailScreen.test.tsx +++ b/src/screens/app/__tests__/ModuleDetailScreen.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react-native'; import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/native'; import { auth } from '../../../services/firebase'; import { getModuleLessonProgress } from '../../../services/models/userProgress'; +import { getModuleDetail } from '../../../services/models/courseContent'; import ModuleDetailScreen from '../ModuleDetailScreen'; jest.mock('react-native-vector-icons/Ionicons', () => 'Ionicons'); @@ -23,11 +24,16 @@ jest.mock('../../../services/models/userProgress', () => ({ getModuleLessonProgress: jest.fn(), })); +jest.mock('../../../services/models/courseContent', () => ({ + getModuleDetail: jest.fn(), +})); + describe('ModuleDetailScreen', () => { const mockedUseFocusEffect = useFocusEffect as jest.MockedFunction; const mockedUseNavigation = useNavigation as jest.MockedFunction; const mockedUseRoute = useRoute as jest.MockedFunction; const mockedGetModuleLessonProgress = getModuleLessonProgress as jest.MockedFunction; + const mockedGetModuleDetail = getModuleDetail as jest.MockedFunction; beforeEach(() => { jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -44,6 +50,18 @@ describe('ModuleDetailScreen', () => { { lessonId: 'les-1-1', completed: true, locked: false }, { lessonId: 'les-1-2', completed: false, locked: true }, ] as never); + mockedGetModuleDetail.mockResolvedValue({ + id: 'mod-1', + title: 'Saudações', + description: 'Aprenda os sinais básicos de saudações em Libras.', + objective: 'Comunicar cumprimentos e apresentações simples.', + iconName: 'hand-wave', + order: 1, + lessons: [ + { id: 'les-1-1', title: 'Olá e Tchau', description: 'Sinais para cumprimentar.', order: 1, xpReward: 50 }, + { id: 'les-1-2', title: 'Apresentações Pessoais', description: 'Aprenda a se apresentar.', order: 2, xpReward: 60 }, + ], + } as never); auth.currentUser = { uid: 'user-123' } as never; }); diff --git a/src/screens/app/__tests__/SignTeachingScreen.test.tsx b/src/screens/app/__tests__/SignTeachingScreen.test.tsx index 04ec417..1529012 100644 --- a/src/screens/app/__tests__/SignTeachingScreen.test.tsx +++ b/src/screens/app/__tests__/SignTeachingScreen.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react-native'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { useNavigation, useRoute } from '@react-navigation/native'; +import { getLessonContent } from '../../../services/models/courseContent'; import SignTeachingScreen from '../SignTeachingScreen'; jest.mock('react-native-vector-icons/Ionicons', () => 'Ionicons'); @@ -10,10 +11,15 @@ jest.mock('@react-navigation/native', () => ({ useRoute: jest.fn(), })); +jest.mock('../../../services/models/courseContent', () => ({ + getLessonContent: jest.fn(), +})); + describe('SignTeachingScreen', () => { const mockNavigate = jest.fn(); const mockedUseNavigation = useNavigation as jest.MockedFunction; const mockedUseRoute = useRoute as jest.MockedFunction; + const mockedGetLessonContent = getLessonContent as jest.MockedFunction; beforeEach(() => { mockNavigate.mockReset(); @@ -24,21 +30,47 @@ describe('SignTeachingScreen', () => { moduleId: 'mod-1', }, } as never); + mockedGetLessonContent.mockResolvedValue({ + id: 'les-1-1', + title: 'Olá e Tchau', + description: 'Sinais para cumprimentar.', + order: 1, + xpReward: 50, + vocabulary: [ + { id: 'v-1-1-1', word: 'Olá', translation: 'Sinal de saudação com a mão aberta' }, + { id: 'v-1-1-2', word: 'Tchau', translation: 'Sinal de despedida acenando a mão' }, + ], + expressions: ['Olá, tudo bem?'], + questions: [ + { + id: 'q-1-1-1', + type: 'multiple_choice', + prompt: 'Qual é o sinal de "Olá" em Libras?', + options: ['Mão aberta acenando', 'Punho fechado'], + correctAnswer: 'Mão aberta acenando', + }, + ], + } as never); }); - it('renders the lesson introduction interface', () => { + it('renders the lesson introduction interface', async () => { render(); - expect(screen.getAllByText('Olá').length).toBeGreaterThan(0); + await waitFor(() => { + expect(screen.getAllByText('Olá').length).toBeGreaterThan(0); + }); expect(screen.getByText('Como fazer o sinal')).toBeTruthy(); expect(screen.getAllByText('Sinal de saudação com a mão aberta').length).toBeGreaterThan(0); expect(screen.getByText('Vocabulário da lição')).toBeTruthy(); expect(screen.getByText('Continuar')).toBeTruthy(); }); - it('navigates to Quiz with lesson questions when continuing', () => { + it('navigates to Quiz with lesson questions when continuing', async () => { render(); + await waitFor(() => { + expect(screen.getByText('Continuar')).toBeTruthy(); + }); fireEvent.press(screen.getByText('Continuar')); expect(mockNavigate).toHaveBeenCalledWith('Quiz', expect.objectContaining({ diff --git a/src/scripts/seedLessons.ts b/src/scripts/seedLessons.ts index 586db4e..16c90d4 100644 --- a/src/scripts/seedLessons.ts +++ b/src/scripts/seedLessons.ts @@ -1,6 +1,9 @@ /** * Script para popular o Firebase com dados das lições. * Executar com: npx ts-node src/scripts/seedLessons.ts + * + * Idempotente: faz upsert por id (createCourse/createLesson usam setDoc e + * addModule faz upsert por moduleId). Reexecutar não duplica nem deleta nada. */ import { MODULES } from '../data/modules'; import { createCourse, addModule, CourseLessonRef, CourseModule } from '../services/models/courses'; @@ -42,6 +45,18 @@ async function seed() { title: lesson.title, courseId: COURSE_ID, moduleId: mod.id, + description: lesson.description, + order: lesson.order, + xpReward: lesson.xpReward, + vocabulary: lesson.vocabulary.map((v) => ({ + id: v.id, + word: v.word, + translation: v.translation, + videoUrl: v.videoUrl, + imageUrl: v.imageUrl, + })), + expressions: lesson.expressions, + grammar: lesson.grammar, questions, }; @@ -58,6 +73,9 @@ async function seed() { moduleId: mod.id, title: mod.title, order: mod.order, + description: mod.description, + objective: mod.objective, + iconName: mod.iconName, lessons: lessonRefs, }; await addModule(COURSE_ID, courseModule); diff --git a/src/services/__tests__/services/models/courseContent.test.ts b/src/services/__tests__/services/models/courseContent.test.ts new file mode 100644 index 0000000..0757e8e --- /dev/null +++ b/src/services/__tests__/services/models/courseContent.test.ts @@ -0,0 +1,90 @@ +jest.mock('../../../models/courses', () => ({ + getCourseById: jest.fn(), +})); + +jest.mock('../../../models/lessons', () => ({ + getLessonById: jest.fn(), +})); + +import { getCourseById } from '../../../models/courses'; +import { getLessonById } from '../../../models/lessons'; +import { getLessonContent, getModuleDetail } from '../../../models/courseContent'; + +describe('courseContent (Firestore-first com fallback)', () => { + const mockedGetCourseById = getCourseById as jest.MockedFunction; + const mockedGetLessonById = getLessonById as jest.MockedFunction; + + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + mockedGetCourseById.mockReset(); + mockedGetLessonById.mockReset(); + }); + + it('lê o módulo do Firestore quando disponível', async () => { + mockedGetCourseById.mockResolvedValue({ + id: 'libras-basico', + title: 'Libras Básico', + modules: [ + { + moduleId: 'mod-1', + title: 'Saudações Remoto', + order: 1, + description: 'Descrição remota', + objective: 'Objetivo remoto', + iconName: 'hand-wave', + lessons: [{ lessonId: 'les-1-1', title: 'Olá e Tchau', type: 'standard', order: 1 }], + }, + ], + } as never); + + const detail = await getModuleDetail('mod-1'); + + expect(detail?.title).toBe('Saudações Remoto'); + expect(detail?.description).toBe('Descrição remota'); + expect(detail?.lessons).toHaveLength(1); + }); + + it('cai no fallback de modules.ts quando o Firestore vem vazio', async () => { + mockedGetCourseById.mockResolvedValue({ id: 'libras-basico', title: 'Libras Básico', modules: [] } as never); + + const detail = await getModuleDetail('mod-1'); + + expect(detail?.id).toBe('mod-1'); + expect(detail?.title).toBe('Saudações'); + }); + + it('cai no fallback quando o Firestore lança erro', async () => { + mockedGetCourseById.mockRejectedValue(new Error('offline')); + + const detail = await getModuleDetail('mod-1'); + + expect(detail?.id).toBe('mod-1'); + expect(detail?.title).toBe('Saudações'); + }); + + it('lê a lição do Firestore quando há vocabulário', async () => { + mockedGetLessonById.mockResolvedValue({ + id: 'les-1-1', + title: 'Olá e Tchau', + courseId: 'libras-basico', + moduleId: 'mod-1', + xpReward: 50, + vocabulary: [{ id: 'v-1', word: 'Olá Remoto', translation: 'tradução remota' }], + expressions: ['Olá!'], + questions: [], + } as never); + + const lesson = await getLessonContent('les-1-1', 'mod-1'); + + expect(lesson?.vocabulary[0].word).toBe('Olá Remoto'); + }); + + it('cai no fallback de modules.ts quando a lição remota vem sem vocabulário', async () => { + mockedGetLessonById.mockResolvedValue(null); + + const lesson = await getLessonContent('les-1-1', 'mod-1'); + + expect(lesson?.id).toBe('les-1-1'); + expect(lesson?.vocabulary[0].word).toBe('Olá'); + }); +}); diff --git a/src/services/models/courseContent.ts b/src/services/models/courseContent.ts new file mode 100644 index 0000000..3799038 --- /dev/null +++ b/src/services/models/courseContent.ts @@ -0,0 +1,173 @@ +import { MODULES } from '../../data/modules'; +import type { Lesson, Module } from '../../data/types'; +import { getCourseById } from './courses'; +import { getLessonById } from './lessons'; + +export const DEFAULT_COURSE_ID = 'libras-basico'; + +export interface ModuleLessonItem { + id: string; + title: string; + description: string; + order: number; + xpReward: number; +} + +export interface ModuleDetail { + id: string; + title: string; + description: string; + objective: string; + iconName: string; + order: number; + lessons: ModuleLessonItem[]; +} + +export interface LessonContent { + id: string; + title: string; + description: string; + order: number; + xpReward: number; + vocabulary: { id: string; word: string; translation: string }[]; + expressions: string[]; + questions: Lesson['activities'][number]['questions']; +} + +// Estratégia Firestore-first com fallback resiliente: +// tenta a fonte remota; se vier vazia ou lançar erro, usa modules.ts offline. +async function firestoreFirst( + remote: () => Promise, + fallback: () => T | undefined +): Promise { + try { + const value = await remote(); + if (value) { + return value; + } + } catch (error) { + console.error('courseContent: falha ao ler do Firestore, usando fallback offline', error); + } + return fallback(); +} + +function moduleToDetail(mod: Module): ModuleDetail { + return { + id: mod.id, + title: mod.title, + description: mod.description, + objective: mod.objective, + iconName: mod.iconName, + order: mod.order, + lessons: mod.lessons + .slice() + .sort((a, b) => a.order - b.order) + .map((lesson) => ({ + id: lesson.id, + title: lesson.title, + description: lesson.description, + order: lesson.order, + xpReward: lesson.xpReward, + })), + }; +} + +function lessonToContent(lesson: Lesson): LessonContent { + return { + id: lesson.id, + title: lesson.title, + description: lesson.description, + order: lesson.order, + xpReward: lesson.xpReward, + vocabulary: lesson.vocabulary.map((item) => ({ + id: item.id, + word: item.word, + translation: item.translation, + })), + expressions: lesson.expressions, + questions: lesson.activities.flatMap((activity) => activity.questions), + }; +} + +function fallbackModule(moduleId: string): ModuleDetail | undefined { + const mod = MODULES.find((item) => item.id === moduleId); + return mod ? moduleToDetail(mod) : undefined; +} + +function fallbackLesson(moduleId: string, lessonId: string): LessonContent | undefined { + const mod = MODULES.find((item) => item.id === moduleId); + const lesson = mod?.lessons.find((item) => item.id === lessonId); + return lesson ? lessonToContent(lesson) : undefined; +} + +export async function getModuleDetail( + moduleId: string, + courseId = DEFAULT_COURSE_ID +): Promise { + return firestoreFirst( + async () => { + const course = await getCourseById(courseId); + const remoteModule = course?.modules?.find((item) => item.moduleId === moduleId); + if (!remoteModule || !remoteModule.lessons?.length) { + return null; + } + const localModule = MODULES.find((item) => item.id === moduleId); + const lessons = remoteModule.lessons + .slice() + .sort((a, b) => a.order - b.order) + .map((ref) => { + const localLesson = localModule?.lessons.find((item) => item.id === ref.lessonId); + return { + id: ref.lessonId, + title: ref.title, + description: localLesson?.description ?? '', + order: ref.order, + xpReward: localLesson?.xpReward ?? 0, + }; + }); + return { + id: remoteModule.moduleId, + title: remoteModule.title, + description: remoteModule.description ?? localModule?.description ?? '', + objective: remoteModule.objective ?? localModule?.objective ?? '', + iconName: remoteModule.iconName ?? localModule?.iconName ?? 'school', + order: remoteModule.order, + lessons, + }; + }, + () => fallbackModule(moduleId) + ); +} + +export async function getLessonContent( + lessonId: string, + moduleId: string, + courseId = DEFAULT_COURSE_ID +): Promise { + return firestoreFirst( + async () => { + const remote = await getLessonById(lessonId); + if (!remote || !remote.vocabulary?.length) { + return null; + } + const localLesson = MODULES.find((item) => item.id === moduleId)?.lessons.find( + (item) => item.id === lessonId + ); + return { + id: remote.id, + title: remote.title, + description: remote.description ?? localLesson?.description ?? '', + order: remote.order ?? localLesson?.order ?? 0, + xpReward: remote.xpReward ?? localLesson?.xpReward ?? 0, + vocabulary: remote.vocabulary.map((item) => ({ + id: item.id, + word: item.word, + translation: item.translation, + })), + expressions: remote.expressions ?? localLesson?.expressions ?? [], + questions: (remote.questions ?? []) as LessonContent['questions'], + }; + }, + () => fallbackLesson(moduleId, lessonId) + ); +} diff --git a/src/services/models/courseOverview.ts b/src/services/models/courseOverview.ts index 1c726e9..60112d6 100644 --- a/src/services/models/courseOverview.ts +++ b/src/services/models/courseOverview.ts @@ -33,6 +33,7 @@ export async function getCourseModulesOverview( const unlockedModules = new Set(progress?.unlockedModules ?? (firstModuleId ? [firstModuleId] : [])); return sortedModules.map((module, index) => { + // Firestore primeiro; modules.ts apenas como fallback offline. const localModule = MODULES.find((item) => item.id === module.moduleId); const totalLessons = module.lessons.length; const completedCount = module.lessons.filter((lesson) => completedLessons.has(lesson.lessonId)).length; @@ -43,9 +44,9 @@ export async function getCourseModulesOverview( return { id: module.moduleId, title: module.title, - description: localModule?.description ?? course.description ?? 'Continue evoluindo neste módulo.', - objective: localModule?.objective ?? 'Concluir as lições deste módulo.', - iconName: localModule?.iconName ?? 'school', + description: module.description ?? localModule?.description ?? course.description ?? 'Continue evoluindo neste módulo.', + objective: module.objective ?? localModule?.objective ?? 'Concluir as lições deste módulo.', + iconName: module.iconName ?? localModule?.iconName ?? 'school', order: module.order, totalLessons, completedLessons: completedCount, diff --git a/src/services/models/courses.ts b/src/services/models/courses.ts index 841d2a7..adb160c 100644 --- a/src/services/models/courses.ts +++ b/src/services/models/courses.ts @@ -27,6 +27,9 @@ export interface CourseModule { moduleId: string; title: string; order: number; + description?: string; + objective?: string; + iconName?: string; lessons: CourseLessonRef[]; } diff --git a/src/services/models/lessons.ts b/src/services/models/lessons.ts index 8c97521..302887a 100644 --- a/src/services/models/lessons.ts +++ b/src/services/models/lessons.ts @@ -46,11 +46,32 @@ export type LessonQuestion = | VideoRecordQuestion | MultipleChoiceVideoQuestion; +export interface LessonVocabularyItem { + id: string; + word: string; + translation: string; + videoUrl?: string; + imageUrl?: string; +} + +export interface LessonGrammarPoint { + id: string; + title: string; + explanation: string; + examples: string[]; +} + export interface StorableLesson { id?: string; title: string; courseId: string; moduleId: string; + description?: string; + order?: number; + xpReward?: number; + vocabulary?: LessonVocabularyItem[]; + expressions?: string[]; + grammar?: LessonGrammarPoint[]; requirements?: string; questions: LessonQuestion[]; createdAt?: any;