From 8915866eb620a562965ff2ad3b61dcd9bda1769e Mon Sep 17 00:00:00 2001 From: Tobiloba Date: Fri, 29 May 2026 18:10:10 +0100 Subject: [PATCH] chore: improve documentation and developer setup guide for TeachLink Mobile --- .env.example | 1 + .husky/pre-commit | 3 -- App.tsx | 33 ++++++++++--------- app/_layout.tsx | 23 +++++++++---- src/config/env.ts | 84 +++++++++++++++++++++++++++++++++++++++++------ 5 files changed, 109 insertions(+), 35 deletions(-) diff --git a/.env.example b/.env.example index 487d67d..1ceaf7d 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,4 @@ EXPO_PUBLIC_APP_ENV=production # Feature Flags EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS=true +EXPO_PUBLIC_STORYBOOK=false diff --git a/.husky/pre-commit b/.husky/pre-commit index d24fdfc..2312dc5 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - npx lint-staged diff --git a/App.tsx b/App.tsx index 70ae326..229c8a3 100644 --- a/App.tsx +++ b/App.tsx @@ -1,31 +1,33 @@ +import * as Font from 'expo-font'; +import { ExpoRoot } from 'expo-router'; +import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; import React, { useEffect, useRef } from 'react'; import { Alert, AppState, AppStateStatus, LogBox } from 'react-native'; import StorybookUI from './.rnstorybook'; import './global.css'; - -import * as Font from 'expo-font'; -import * as SplashScreen from 'expo-splash-screen'; import { ErrorBoundary } from './src/components/common/ErrorBoundary'; +import { requireEnvVariables } from './src/config/env'; import { initializeLogging } from './src/config/logging'; import { AuthProvider, useAdaptiveTheme } from './src/hooks'; -import AppNavigator from './src/navigation/AppNavigator'; import { setupNotificationNavigation } from './src/navigation/linking'; -import { apiClient } from './src/services/api'; -import { crashReportingService } from './src/services/cashReporting'; +import apiClient from './src/services/api/axios.config'; +import { requestQueue } from './src/services/api/requestQueue'; +import { crashReportingService } from './src/services/crashReporting'; import { mobileAuthService } from './src/services/mobileAuth'; import { addNotificationReceivedListener, getLastNotificationResponse, + registerForPushNotifications, + registerTokenWithBackend, removeNotificationListener, } from './src/services/pushNotifications'; -import { requestQueue } from './src/services/requestQueue'; +import { initializeSecureStorage } from './src/services/secureStorage'; import socketService from './src/services/socket'; -import syncService from './src/services/syncService'; -import { useAppStore } from './src/store'; +import { syncService } from './src/services/syncService'; +import { useAppStore, useNotificationStore } from './src/store'; import { handleCacheVersionUpdate } from './src/utils/cacheVersioning'; -import { requireEnvVariables } from './src/utils/env'; import { appLogger } from './src/utils/logger'; import { handleNotificationReceived } from './src/utils/notificationHandlers'; @@ -35,7 +37,6 @@ SplashScreen.preventAutoHideAsync(); // SHOW_STORYBOOK flag based on environment variable const SHOW_STORYBOOK = process.env.EXPO_PUBLIC_STORYBOOK === 'true'; - // Centralized structured logging initialized on startup requireEnvVariables(); @@ -56,7 +57,7 @@ if (__DEV__) { } const App = () => { - const theme = useAppStore((state) => state.theme); + const theme = useAppStore(state => state.theme); useAdaptiveTheme(); const appStateRef = useRef(AppState.currentState); @@ -98,8 +99,8 @@ const App = () => { crashReportingService.init(); // Initialize secure storage (Keychain/Keystore) for encrypted token storage - initializeSecureStorage().catch((error) => { - logger.error('Failed to initialize secure storage:', error); + initializeSecureStorage().catch(error => { + appLogger.errorSync('Failed to initialize secure storage:', error as Error); // Continue app startup even if secure storage init fails // (user will be prompted to re-authenticate if needed) }); @@ -121,7 +122,7 @@ const App = () => { socketService.connect(); // Initialize push notifications: request permissions and get device token - registerForPushNotifications().then(async (token) => { + registerForPushNotifications().then(async token => { if (token) { const { setPushToken, setTokenRegistered } = useNotificationStore.getState(); setPushToken(token); @@ -235,7 +236,7 @@ const App = () => { - + ); diff --git a/app/_layout.tsx b/app/_layout.tsx index 6ed18a9..699fae9 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,19 +1,18 @@ -import { Stack, useRouter, usePathname, useSegments } from 'expo-router'; +import { Stack, usePathname, useRouter, useSegments } from 'expo-router'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useRef } from 'react'; import { Alert } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import 'react-native-reanimated'; -import '../global.css'; // NativeWind CSS import { MemoryProfilerOverlay } from '../components/DevTools'; import { RetryErrorBoundary } from '../components/ErrorBoundary/RetryErrorBoundary'; - +import '../global.css'; // NativeWind CSS import { AnalyticsProvider, ErrorBoundary, OfflineIndicatorProvider } from '../src/components'; import { useAnalytics } from '../src/hooks'; import { useDeepLink } from '../src/hooks/useDeepLink'; -import { sessionRestorationService } from '../src/services/sessionRestoration'; import { preloadService } from '../src/services/preloadService'; +import { sessionRestorationService } from '../src/services/sessionRestoration'; import { useAppStore } from '../src/store'; import { getPathFromDeepLink } from '../src/utils/linkParser'; import { prefetchExternalResources } from '../src/utils/resourceHints'; @@ -36,7 +35,7 @@ const ScreenTracker = () => { useEffect(() => { if (pathname) { trackScreen(pathname, { segments: segments.join('/') }); - + // Track and record transitions + trigger predictive preloading if (prevPathname.current !== pathname) { @@ -48,7 +47,7 @@ const ScreenTracker = () => { } sessionRestorationService.saveRoute(pathname); - + // Trigger background preloading for predicted destinations preloadService.preload(pathname, router); } @@ -138,4 +137,16 @@ const RootLayout = () => { + + + + + + + + + + ); +}; +export default RootLayout; diff --git a/src/config/env.ts b/src/config/env.ts index f9cf9f0..d3a052a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,9 +1,23 @@ +import { ValidationResult } from '../utils/validation'; + +export interface EnvConfig { + EXPO_PUBLIC_API_BASE_URL: string; + EXPO_PUBLIC_SOCKET_URL: string; + EXPO_PUBLIC_APP_ENV?: 'development' | 'production'; + EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS?: 'true' | 'false'; + EXPO_PUBLIC_STORYBOOK?: 'true' | 'false'; +} + +const REQUIRED_VARIABLES: (keyof EnvConfig)[] = [ + 'EXPO_PUBLIC_API_BASE_URL', + 'EXPO_PUBLIC_SOCKET_URL', +]; + export function validateEnvVariables(): ValidationResult { const missing: string[] = []; const errors: string[] = []; for (const variable of REQUIRED_VARIABLES) { - // Replace dynamic access with direct variable checks let value: string | undefined; if (variable === 'EXPO_PUBLIC_API_BASE_URL') { @@ -16,18 +30,28 @@ export function validateEnvVariables(): ValidationResult { missing.push(variable); errors.push( `Missing required environment variable: ${variable}. ` + - `Please set ${variable} in your .env file. ` + - `See .env.example for reference.` + `Please set ${variable} in your .env file. See .env.example for reference.` ); - } else if (variable === 'EXPO_PUBLIC_API_BASE_URL') { + continue; + } + + if (variable === 'EXPO_PUBLIC_API_BASE_URL') { try { - new URL(value); + const url = new URL(value); + if (url.protocol !== 'https:') { + errors.push( + `Invalid URL for ${variable}: ${value}. ` + + `EXPO_PUBLIC_API_BASE_URL must use https://.` + ); + } } catch { errors.push( - `Invalid URL for ${variable}: ${value}. ` + `Please provide a valid HTTP/HTTPS URL.` + `Invalid URL for ${variable}: ${value}. ` + `Please provide a valid https:// URL.` ); } - } else if (variable === 'EXPO_PUBLIC_SOCKET_URL') { + } + + if (variable === 'EXPO_PUBLIC_SOCKET_URL') { if (!value.startsWith('ws://') && !value.startsWith('wss://')) { errors.push( `Invalid WebSocket URL for ${variable}: ${value}. ` + @@ -37,18 +61,58 @@ export function validateEnvVariables(): ValidationResult { } } + if (process.env.EXPO_PUBLIC_APP_ENV) { + const envValue = process.env.EXPO_PUBLIC_APP_ENV; + if (envValue !== 'development' && envValue !== 'production') { + errors.push( + `Invalid value for EXPO_PUBLIC_APP_ENV: ${envValue}. ` + + `Allowed values are 'development' or 'production'.` + ); + } + } + + if (process.env.EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS) { + const pushValue = process.env.EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS; + if (pushValue !== 'true' && pushValue !== 'false') { + errors.push( + `Invalid value for EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS: ${pushValue}. ` + + `Allowed values are 'true' or 'false'.` + ); + } + } + + if (process.env.EXPO_PUBLIC_STORYBOOK) { + const storyValue = process.env.EXPO_PUBLIC_STORYBOOK; + if (storyValue !== 'true' && storyValue !== 'false') { + errors.push( + `Invalid value for EXPO_PUBLIC_STORYBOOK: ${storyValue}. ` + + `Allowed values are 'true' or 'false'.` + ); + } + } + return { valid: missing.length === 0 && errors.length === 0, - missing, - errors, + message: errors.length > 0 ? errors.join(' ') : undefined, }; } export function requireEnvVariables(): EnvConfig { - // ... rest of function + const validation = validateEnvVariables(); + + if (!validation.valid) { + throw new Error( + `Environment Configuration Error: ${validation.message ?? 'Invalid .env values.'}` + ); + } + return { EXPO_PUBLIC_API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL!, EXPO_PUBLIC_SOCKET_URL: process.env.EXPO_PUBLIC_SOCKET_URL!, + EXPO_PUBLIC_APP_ENV: + process.env.EXPO_PUBLIC_APP_ENV === 'production' ? 'production' : 'development', + EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS: process.env.EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS, + EXPO_PUBLIC_STORYBOOK: process.env.EXPO_PUBLIC_STORYBOOK, }; }