Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"@react-navigation/native": "^7.1.8",
"@react-navigation/native-stack": "^7.3.16",
"@sentry/react-native": "~7.2.0",
"@testing-library/react-hooks": "^8.0.1",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"expo": "~54.0.33",
Expand Down
Empty file.
Empty file.
Empty file.
57 changes: 0 additions & 57 deletions src/hooks/useBiometricAuth.ts
Original file line number Diff line number Diff line change
@@ -1,57 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, AppStateStatus } from 'react-native';

import { BiometricType, AuthResult } from '../services/mobileAuth';
import { isBiometricEnabled } from '../services/secureStorage';
import { useDeviceStore } from '../store/deviceStore';

export function useBiometricAuth() {
const isDeviceCompromised = useDeviceStore(state => state.isDeviceCompromised);
const biometricEnabled = useDeviceStore(state => state.biometricEnabled);
const setBiometricEnabled = useDeviceStore(state => state.setBiometricEnabled);

const [isSyncing, setIsSyncing] = useState(true);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);

const syncWithSecureStore = useCallback(async () => {
setIsSyncing(true);
try {
const enabled = await isBiometricEnabled();
// If secure store no longer has the entry, reset to the safe default.
// This covers: OS update wiping the keychain, app reinstall, or any
// external process clearing the key while the app was backgrounded.
setBiometricEnabled(enabled);
} finally {
setIsSyncing(false);
}
}, [setBiometricEnabled]);

// Sync once on mount so the initial render reflects SecureStore truth.
useEffect(() => {
syncWithSecureStore();
}, [syncWithSecureStore]);

// Re-sync on every foreground transition so a stale Zustand value is
// corrected before any biometric UI can appear.
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
if (appStateRef.current !== 'active' && nextState === 'active') {
syncWithSecureStore();
}
appStateRef.current = nextState;
});
return () => subscription.remove();
}, [syncWithSecureStore]);

return {
isAvailable: false,
isEnabled: !isDeviceCompromised && biometricEnabled,
biometricType: 'none' as BiometricType,
authenticate: useCallback(async (): Promise<AuthResult | null> => null, []),
enable: useCallback(async () => false, []),
disable: useCallback(async () => {}, []),
isLoading: isSyncing,
error: isDeviceCompromised ? 'Biometric authentication is unavailable on this device.' : null,
clearError: useCallback(() => {}, []),
};
}
2 changes: 1 addition & 1 deletion src/services/mobileAuth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

import logger from '../utils/logger';
import apiClient from './api/axios.config';
import * as secureStorage from './secureStorage';
import logger from '../utils/logger';

// ─── Types ────────────────────────────────────────────────────────────────────

Expand Down
2 changes: 1 addition & 1 deletion src/store/deviceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ export const useDeviceStore = create<DeviceState>(set => ({
set({ isDeviceCompromised: compromised });
return compromised;
},
}));
}));
147 changes: 147 additions & 0 deletions src/store/{slices}/courseProgressStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { showErrorToast } from '@utils/toast';
import { create } from 'zustand';
import { appLogger } from '../../utils/logger';

// ─── Types ────────────────────────────────────────────────────────────────────

export interface LessonProgress {
lessonId: string;
completedAt: string;
}

export interface CourseProgress {
courseId: string;
completedLessons: LessonProgress[];
totalLessons: number;
isCompleted: boolean;
completedAt?: string;
}

interface CourseProgressState {
progress: Record<string, CourseProgress>;
isUpdating: boolean;
completeLesson: (courseId: string, lessonId: string) => Promise<void>;
}

// ─── Event helpers ────────────────────────────────────────────────────────────

function emitCourseCompleted(courseId: string) {
// Replace with your actual event bus / analytics call.
// Kept as a named function so unit tests can spy on it.
globalThis.dispatchEvent?.(new CustomEvent('courseCompleted', { detail: { courseId } }));
}

// ─── Server persist with retry ────────────────────────────────────────────────

async function updateProgressOnServer(
courseId: string,
payload: { completedLessons: LessonProgress[]; isCompleted: boolean; completedAt?: string },
retries = 3,
): Promise<void> {
let lastError: unknown;

for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await fetch(`/api/courses/${courseId}/progress`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});

// 200 OK → success (idempotent: already-completed courses also return 200)
// 2xx → treat as success
if (response.ok) return;

// Non-2xx: surface the status so the catch branch can log it.
throw new Error(`Server responded with ${response.status}`);
} catch (err) {
lastError = err;
await appLogger.warn(`updateProgressOnServer attempt ${attempt}/${retries} failed`, err);

if (attempt < retries) {
// Exponential back-off: 500 ms, 1 000 ms, 2 000 ms …
await new Promise(r => setTimeout(r, 500 * attempt));
}
}
}

throw lastError;
}

// ─── Store ────────────────────────────────────────────────────────────────────

export const useCourseProgressStore = create<CourseProgressState>((set, get) => ({
progress: {},
isUpdating: false,

completeLesson: async (courseId: string, lessonId: string) => {
const current = get().progress[courseId];
if (!current) {
await appLogger.error('completeLesson called for unknown courseId', { courseId, lessonId });
return;
}

// Avoid duplicate lesson completions.
const alreadyRecorded = current.completedLessons.some(l => l.lessonId === lessonId);
if (alreadyRecorded) return;

const newLesson: LessonProgress = { lessonId, completedAt: new Date().toISOString() };
const updatedLessons = [...current.completedLessons, newLesson];
const reachedTotal = updatedLessons.length >= current.totalLessons;

const updatedProgress: CourseProgress = {
...current,
completedLessons: updatedLessons,
// Do NOT mark isCompleted yet — we wait for server confirmation.
isCompleted: current.isCompleted,
};

// Optimistically update local state so the UI reflects the new lesson.
set(state => ({
isUpdating: true,
progress: { ...state.progress, [courseId]: updatedProgress },
}));

try {
const serverPayload = {
completedLessons: updatedLessons,
isCompleted: reachedTotal,
...(reachedTotal ? { completedAt: new Date().toISOString() } : {}),
};

// ── Critical ordering: server must confirm before the event fires. ──
await updateProgressOnServer(courseId, serverPayload);

// Server confirmed — now commit the final state locally.
const confirmedProgress: CourseProgress = {
...updatedProgress,
isCompleted: reachedTotal,
...(reachedTotal ? { completedAt: serverPayload.completedAt } : {}),
};

set(state => ({
isUpdating: false,
progress: { ...state.progress, [courseId]: confirmedProgress },
}));

// ── Event fires only after server confirmation. ──
if (reachedTotal) {
emitCourseCompleted(courseId);
}
} catch (err) {
await appLogger.error('Failed to persist lesson progress after retries', err, {
courseId,
lessonId,
});

// Roll back the optimistic local update so state stays consistent.
set(state => ({
isUpdating: false,
progress: { ...state.progress, [courseId]: current },
}));

// Surface a toast — do NOT emit courseCompleted.
showErrorToast('Could not save your progress. Please check your connection and try again.');
}
},
}));
45 changes: 45 additions & 0 deletions src/utils/toast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// ─── Toast helpers ────────────────────────────────────────────────────────────
// Wraps whatever toast library the project uses (e.g. react-native-toast-message,
// burnt, react-hot-toast) behind a stable interface so imports don't scatter
// library-specific calls throughout the codebase.

export type ToastType = 'success' | 'error' | 'info' | 'warning';

export interface ToastOptions {
/** How long the toast stays visible in milliseconds. Default: 4000 */
duration?: number;
/** Optional action label shown alongside the message. */
actionLabel?: string;
onActionPress?: () => void;
}

function show(type: ToastType, message: string, options: ToastOptions = {}): void {
// Real implementation — swap the body for your toast library:
//
// Toast.show({
// type,
// text1: message,
// visibilityTime: options.duration ?? 4000,
// });
//
// For now, fall back to console so nothing crashes in tests or stubs.
const prefix = `[${type.toUpperCase()}]`;
// eslint-disable-next-line no-console
console.log(`${prefix} ${message}`);
}

export function showSuccessToast(message: string, options?: ToastOptions): void {
show('success', message, options);
}

export function showErrorToast(message: string, options?: ToastOptions): void {
show('error', message, options);
}

export function showInfoToast(message: string, options?: ToastOptions): void {
show('info', message, options);
}

export function showWarningToast(message: string, options?: ToastOptions): void {
show('warning', message, options);
}
Loading
Loading