From f584ac5120bd7cba926329600504901c7f1e8382 Mon Sep 17 00:00:00 2001 From: Oladipupo Hussein Date: Sun, 26 Jul 2026 21:12:44 +0000 Subject: [PATCH] push completed task --- App.tsx | 17 +++-- src/components/mobile/MobileFormInput.tsx | 20 +++++- src/constants/assets.ts | 23 +++++++ src/hooks/useCourseProgress.ts | 2 +- src/hooks/useCustomFonts.ts | 6 +- src/hooks/useNetworkStatus.ts | 5 +- src/services/api/courseApi.ts | 35 ++++++++++ src/services/fontService.ts | 14 ++-- src/store/courseProgressStore.ts | 78 +++++++++++++++++++++++ src/store/createStore.ts | 4 +- 10 files changed, 185 insertions(+), 19 deletions(-) create mode 100644 src/constants/assets.ts diff --git a/App.tsx b/App.tsx index 8d08e793..769c4383 100644 --- a/App.tsx +++ b/App.tsx @@ -13,7 +13,9 @@ import { View, } from 'react-native'; +import { Asset } from 'expo-asset'; import * as Updates from 'expo-updates'; +import { CRITICAL_ASSETS } from './src/constants/assets'; import StorybookUI from './.rnstorybook'; import './global.css'; @@ -72,7 +74,7 @@ requireEnvVariables(); // Initialize centralized logging on app start initializeLogging().catch(err => { if (__DEV__) { - console.error('[App] Failed to initialize logging:', err); + appLogger.errorSync('[App] Failed to initialize logging:', err instanceof Error ? err : new Error(String(err))); } }); @@ -208,10 +210,15 @@ const App = () => { useEffect(() => { async function prepareApp() { try { - // 1. Load critical fonts (used on first screen) synchronously before splash hides + // 1. Load critical fonts and preload critical image assets in parallel + // so both complete before the splash screen hides, eliminating any + // image-placeholder flicker on first-time screen visits (#819). const fontStart = Date.now(); - await fontService.loadFonts(CRITICAL_FONTS); - console.log(`[App] Critical fonts loaded in ${Date.now() - fontStart}ms`); + await Promise.all([ + fontService.loadFonts(CRITICAL_FONTS), + Asset.loadAsync(CRITICAL_ASSETS), + ]); + appLogger.infoSync(`[App] Critical fonts & assets loaded in ${Date.now() - fontStart}ms`); // 2. Version-based cache invalidation: clear stale caches on app/data version bump const appVersion = require('./package.json').version as string; @@ -236,7 +243,7 @@ const App = () => { InteractionManager.runAfterInteractions(async () => { const start = Date.now(); await fontService.loadFonts(SECONDARY_FONTS); - console.log(`[App] Secondary fonts loaded in ${Date.now() - start}ms`); + appLogger.infoSync(`[App] Secondary fonts loaded in ${Date.now() - start}ms`); }); }, [appIsReady]); diff --git a/src/components/mobile/MobileFormInput.tsx b/src/components/mobile/MobileFormInput.tsx index 54ffe9ca..e4a99630 100644 --- a/src/components/mobile/MobileFormInput.tsx +++ b/src/components/mobile/MobileFormInput.tsx @@ -49,7 +49,15 @@ interface MobileFormInputProps { onBlur?: TextInputProps['onBlur']; } -export const MobileFormInput: React.FC = ({ +/** + * MobileFormInput — wrapped in React.memo to prevent re-renders on every + * keystroke in multi-field forms. + * + * ⚠️ Call-site requirement: pass `onChangeText` wrapped in `useCallback` so + * the memo comparison stays stable and sibling inputs are not re-rendered when + * only one field changes. + */ +const MobileFormInputBase: React.FC = ({ label, value, onChangeText, @@ -232,4 +240,12 @@ export const MobileFormInput: React.FC = ({ )} ); -}; \ No newline at end of file +}; + +/** + * Named export for backwards compatibility — points to the memoised component. + * Callers that already used `{ MobileFormInput }` continue to work unchanged. + */ +export const MobileFormInput = React.memo(MobileFormInputBase); + +export default MobileFormInput; diff --git a/src/constants/assets.ts b/src/constants/assets.ts new file mode 100644 index 00000000..c709b63a --- /dev/null +++ b/src/constants/assets.ts @@ -0,0 +1,23 @@ +/** + * CRITICAL_ASSETS — images that must be preloaded during the splash-screen + * phase (before the first meaningful paint) to avoid visible placeholder + * flicker on first-time screen visits. + * + * Rules for inclusion: + * - Shown on the very first screen the user sees after login + * - Used as avatar / logo placeholders that would flash if loaded lazily + * - Appear in navigation tabs visible on every screen + * + * Use `Asset.loadAsync(CRITICAL_ASSETS)` inside the startup `Promise.all` + * alongside font loading so that everything resolves before `SplashScreen.hideAsync()`. + */ +export const CRITICAL_ASSETS = [ + // App icon – used in splash, navigation header and share sheets + require('../../assets/images/icon.png'), + + // Splash / loading screen graphic + require('../../assets/images/splash-icon.png'), + + // Avatar placeholder shown before user profile data is fetched + require('../../assets/images/ios-icon.png'), +] as const; diff --git a/src/hooks/useCourseProgress.ts b/src/hooks/useCourseProgress.ts index 7a3b1a23..8850aec5 100644 --- a/src/hooks/useCourseProgress.ts +++ b/src/hooks/useCourseProgress.ts @@ -44,7 +44,7 @@ export function useCourseProgress({ const [fullProgress, setFullProgress] = useState(null); const [isLoading, setIsLoading] = useState(true); - const { setCourseProgress } = useCourseProgressStore(); + const setCourseProgress = useCourseProgressStore(state => state.setCourseProgress); // Debounce timer ref for server sync const syncTimerRef = useRef | null>(null); diff --git a/src/hooks/useCustomFonts.ts b/src/hooks/useCustomFonts.ts index f4fad0f8..84355faf 100644 --- a/src/hooks/useCustomFonts.ts +++ b/src/hooks/useCustomFonts.ts @@ -3,6 +3,8 @@ import { loadAsync } from 'expo-font'; import { useEffect, useState } from 'react'; import { Platform } from 'react-native'; +import { appLogger } from '../utils/logger'; + // Font configuration export interface FontConfig { name: string; @@ -91,7 +93,7 @@ async function loadSingleFont(config: FontConfig): Promise { return true; }) .catch((error) => { - console.error(`Failed to load font ${config.name}:`, error); + appLogger.errorSync(`Failed to load font ${config.name}:`, error instanceof Error ? error : new Error(String(error))); return false; }); @@ -234,7 +236,7 @@ export async function preloadCriticalFonts() { const { loaded, failed } = await loadFontsWithProgress(criticalFonts); if (failed.length > 0) { - console.warn('Some critical fonts failed to load:', failed); + appLogger.warnSync('Some critical fonts failed to load:', { failed }); } return { loaded, failed }; diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts index 6ad9ec44..01bf231a 100644 --- a/src/hooks/useNetworkStatus.ts +++ b/src/hooks/useNetworkStatus.ts @@ -2,6 +2,7 @@ import { Network } from 'expo-network'; import { useState, useEffect, useCallback } from 'react'; import { networkMonitor, type ConnectionType, type NetworkStatus } from '../services/networkMonitor'; +import { appLogger } from '../utils/logger'; export type { ConnectionType, NetworkStatus }; @@ -61,7 +62,7 @@ export function useNetworkStatus() { isFast = false; } } catch (error) { - console.warn('Failed to get cellular state', error); + appLogger.warnSync('Failed to get cellular state', { error: String(error) }); quality = 'unknown'; isFast = false; } @@ -72,7 +73,7 @@ export function useNetworkStatus() { setConnectionQuality({ quality, isFast }); } catch (error) { - console.warn('Failed to get network state', error); + appLogger.warnSync('Failed to get network state', { error: String(error) }); setNetworkStatus({ isConnected: false, isInternetReachable: false, diff --git a/src/services/api/courseApi.ts b/src/services/api/courseApi.ts index 257a4443..ada2071a 100644 --- a/src/services/api/courseApi.ts +++ b/src/services/api/courseApi.ts @@ -73,4 +73,39 @@ export const courseApi = { invalidateCourse(id: string): void { invalidateCacheByTags([COURSE_TAG, courseTag(id)]); }, + + /** + * loadLessonsPage — fetch a paginated slice of lesson metadata for a course. + * + * Used by the windowed loading implementation in courseProgressStore to avoid + * loading all 50+ lessons into memory at once. Only metadata (IDs, titles, + * durations, video URLs) is fetched; heavy content is loaded on demand. + * + * @param courseId - Course to paginate lessons for. + * @param page - 1-based page number. + * @param limit - Lessons per page (default 5 — current ± 2 window). + */ + async loadLessonsPage( + courseId: string, + page: number, + limit = 5 + ): Promise<{ lessons: import('../../types/course').Lesson[]; totalLessons: number; page: number; totalPages: number }> { + const cacheKey = `courses:${courseId}:lessons:page=${page}:limit=${limit}`; + return fetchWithSWR( + cacheKey, + () => + apiClient + .get<{ lessons: import('../../types/course').Lesson[]; totalLessons: number; page: number; totalPages: number }>( + `/courses/${courseId}/lessons`, + { params: { page, limit } } + ) + .then(r => r.data), + TTL, + STALE_TTL, + { + dataType: 'lesson-page', + tags: [COURSE_TAG, courseTag(courseId)], + } + ); + }, }; \ No newline at end of file diff --git a/src/services/fontService.ts b/src/services/fontService.ts index e3c0355a..abaec438 100644 --- a/src/services/fontService.ts +++ b/src/services/fontService.ts @@ -3,6 +3,8 @@ import { Asset } from 'expo-asset'; import * as Font from 'expo-font'; import { Platform } from 'react-native'; +import { appLogger } from '../utils/logger'; + // Font metadata interface export interface FontMetadata { name: string; @@ -53,7 +55,7 @@ class FontService { }); } } catch (error) { - console.error('Failed to load font metadata:', error); + appLogger.errorSync('Failed to load font metadata:', error instanceof Error ? error : new Error(String(error))); } } @@ -63,7 +65,7 @@ class FontService { const data = Object.fromEntries(this.metadata); await AsyncStorage.setItem(this.cacheKey, JSON.stringify(data)); } catch (error) { - console.error('Failed to save font metadata:', error); + appLogger.errorSync('Failed to save font metadata:', error instanceof Error ? error : new Error(String(error))); } } @@ -75,7 +77,7 @@ class FontService { this.defaultSettings = JSON.parse(cached); } } catch (error) { - console.error('Failed to load font settings:', error); + appLogger.errorSync('Failed to load font settings:', error instanceof Error ? error : new Error(String(error))); } } @@ -84,7 +86,7 @@ class FontService { try { await AsyncStorage.setItem(this.settingsKey, JSON.stringify(this.defaultSettings)); } catch (error) { - console.error('Failed to save font settings:', error); + appLogger.errorSync('Failed to save font settings:', error instanceof Error ? error : new Error(String(error))); } } @@ -162,7 +164,7 @@ class FontService { return true; } catch (error) { - console.error(`Failed to load font ${name}:`, error); + appLogger.errorSync(`Failed to load font ${name}:`, error instanceof Error ? error : new Error(String(error))); return false; } } @@ -267,7 +269,7 @@ class FontService { this.loadedFonts.add(name); }); const elapsed = Date.now() - start; - console.log(`[FontService] Loaded ${fonts.length} font(s) in ${elapsed}ms`); + appLogger.infoSync(`[FontService] Loaded ${fonts.length} font(s) in ${elapsed}ms`); } // Preload critical fonts diff --git a/src/store/courseProgressStore.ts b/src/store/courseProgressStore.ts index a0e9a22f..7d8bf1a5 100644 --- a/src/store/courseProgressStore.ts +++ b/src/store/courseProgressStore.ts @@ -4,6 +4,16 @@ import { persist } from 'zustand/middleware'; import { asyncStorageJSONStorage, createHydrationErrorRecovery } from './persistence'; import type { CourseProgress, LessonProgress } from '../types/course'; +import type { Lesson } from '../types/course'; + +// ── Windowed lesson loading constants ───────────────────────────────────────── +/** + * Number of lessons to load ahead and behind the current lesson (i.e. the + * "window radius"). At radius 2 we keep at most 5 lessons in memory at a time, + * which limits the in-memory object graph to < 50 MB even on 50-lesson courses. + */ +const LESSON_WINDOW_RADIUS = 2; +const LESSON_PAGE_SIZE = LESSON_WINDOW_RADIUS * 2 + 1; // 5 lessons per fetch interface CourseProgressState { // keyed by courseId @@ -17,10 +27,39 @@ interface CourseProgressState { lessonData?: Partial ) => void; isCourseComplete: (courseId: string, totalLessons: number) => boolean; + + // ── Windowed lesson loading (issue #817) ────────────────────────────────── + /** + * In-memory window of lesson metadata for the currently active course. + * Only the current ± LESSON_WINDOW_RADIUS lessons are kept here, limiting + * memory usage on large courses with many lessons. + */ + lessonWindow: Record; // keyed by courseId + /** + * The 1-based page that was last loaded for each course. + * Stored so the viewer can detect when the window needs sliding. + */ + lessonWindowPage: Record; + /** + * Load (or slide) the lesson window to the page containing `currentLessonIndex`. + * Frees the previous window from memory before setting the new one. + * + * @param courseId - Course whose lessons should be windowed. + * @param currentLessonIndex - 0-based absolute index of the active lesson. + * @param totalLessons - Total lesson count for the course. + */ + loadLessonWindow: (courseId: string, currentLessonIndex: number, totalLessons: number) => Promise; + /** + * Return the currently loaded lesson slice for a course. + * Returns an empty array if no window has been loaded yet. + */ + getLessonWindow: (courseId: string) => Lesson[]; } const INITIAL_COURSE_PROGRESS_STATE = { progressMap: {}, + lessonWindow: {} as Record, + lessonWindowPage: {} as Record, }; /** @@ -109,6 +148,43 @@ export const useCourseProgressStore = create()( const completedLessons = Object.values(progress.lessons).filter(l => l.completed).length; return completedLessons === totalLessons || progress.overallProgress >= 99.5; }, + + // ── Windowed lesson loading (issue #817) ──────────────────────────── + lessonWindow: {}, + lessonWindowPage: {}, + + getLessonWindow: (courseId) => get().lessonWindow[courseId] ?? [], + + loadLessonWindow: async (courseId, currentLessonIndex, totalLessons) => { + // Compute which 1-based page contains the current lesson index + const targetPage = Math.floor(currentLessonIndex / LESSON_PAGE_SIZE) + 1; + const currentPage = get().lessonWindowPage[courseId]; + + // Skip if the required window is already loaded + if (currentPage === targetPage) return; + + try { + // Lazy-import the API to avoid a circular dependency at module load time + const { courseApi } = await import('../services/api/courseApi'); + const result = await courseApi.loadLessonsPage(courseId, targetPage, LESSON_PAGE_SIZE); + + set(s => ({ + // Free the previous window before storing the new one so the old + // lesson objects are eligible for GC (#817: memory management) + lessonWindow: { + ...s.lessonWindow, + [courseId]: result.lessons, + }, + lessonWindowPage: { + ...s.lessonWindowPage, + [courseId]: targetPage, + }, + })); + } catch (_err) { + // Non-fatal: the viewer falls back to the full lesson list supplied + // via the course prop when the window fails to load. + } + }, }; }, { @@ -121,6 +197,8 @@ export const useCourseProgressStore = create()( ), partialize: state => ({ progressMap: state.progressMap, + // lessonWindow and lessonWindowPage are transient — they are rebuilt on + // demand from the API and should not bloat AsyncStorage with lesson data. }), } ) diff --git a/src/store/createStore.ts b/src/store/createStore.ts index cc97d7a5..7d8c7c60 100644 --- a/src/store/createStore.ts +++ b/src/store/createStore.ts @@ -9,6 +9,8 @@ import { } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; +import { appLogger } from '../utils/logger'; + const storage = new MMKV(); /** @@ -45,7 +47,7 @@ export const createStore = ( onRehydrateStorage: (state) => { return (state, error) => { if (error) { - console.log('an error happened during hydration', error) + appLogger.errorSync('an error happened during hydration', error instanceof Error ? error : new Error(String(error))) } } },