diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 3cf8fa8b..8fe8bf34 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,5 +1,5 @@
import { Tabs } from 'expo-router';
-import React from 'react';
+import React, { useMemo } from 'react';
import { HapticTab } from '@/components/haptic-tab';
import { IconSymbol } from '@/components/ui/icon-symbol';
@@ -7,19 +7,24 @@ import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { ErrorBoundary } from '@/src/components';
-export default function TabLayout() {
+const TabLayout = () => {
const colorScheme = useColorScheme();
+ const screenOptions = useMemo(
+ () => ({
+ tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
+ headerShown: false,
+ tabBarButton: HapticTab,
+ }),
+ [colorScheme]
+ );
+
return (
);
-}
+};
+
+export default TabLayout;
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 63a26101..591b89ac 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -10,7 +10,7 @@ import { AnalyticsProvider, ErrorBoundary, OfflineIndicatorProvider } from '../s
import { useAnalytics } from '../src/hooks';
import { useDeepLink } from '../src/hooks/useDeepLink';
import { sessionRestorationService } from '../src/services/sessionRestoration';
-import { useAppStore } from '../src/store';
+import { useTheme } from '../src/store/selectors';
import { getPathFromDeepLink } from '../src/utils/linkParser';
import { prefetchExternalResources } from '../src/utils/resourceHints';
@@ -40,7 +40,7 @@ const ScreenTracker = () => {
// Sync global theme to NativeWind colorScheme
const ThemeSync = () => {
- const { theme } = useAppStore();
+ const theme = useTheme();
const { setColorScheme } = useColorScheme();
useEffect(() => {
diff --git a/src/components/common/AppText.tsx b/src/components/common/AppText.tsx
index abfca2fd..f6894ddf 100644
--- a/src/components/common/AppText.tsx
+++ b/src/components/common/AppText.tsx
@@ -1,5 +1,6 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import { Text as RNText, TextProps, StyleSheet } from 'react-native';
+
import { useDynamicFontSize } from '../../hooks';
interface AppTextProps extends TextProps {
@@ -10,34 +11,25 @@ interface AppTextProps extends TextProps {
fixed?: boolean;
}
-/**
- * A wrapper around React Native's Text component that uses the useDynamicFontSize hook
- * to ensure consistent scaling across the application.
- */
-export const AppText: React.FC = ({ style, fixed = false, ...props }) => {
- const { scale } = useDynamicFontSize();
-
- // We flatten the style to easily extract and modify the fontSize
- const flattenedStyle = StyleSheet.flatten(style) || {};
+const AppTextInner: React.FC = ({ style, fixed = false, ...props }) => {
+ const { fontScale } = useDynamicFontSize();
- const dynamicStyle = { ...flattenedStyle };
+ const dynamicStyle = useMemo(() => {
+ const flat = StyleSheet.flatten(style) || {};
+ if (fixed || !flat.fontSize) return flat;
- if (!fixed && flattenedStyle.fontSize) {
- dynamicStyle.fontSize = scale(flattenedStyle.fontSize);
+ return {
+ ...flat,
+ fontSize: flat.fontSize * fontScale,
+ ...(flat.lineHeight ? { lineHeight: flat.lineHeight * fontScale } : {}),
+ };
+ }, [style, fixed, fontScale]);
- // Also scale lineHeight if it exists to maintain proportions
- if (flattenedStyle.lineHeight) {
- dynamicStyle.lineHeight = scale(flattenedStyle.lineHeight);
- }
- }
-
- return (
-
- );
+ return ;
};
+
+/**
+ * A wrapper around React Native's Text component that uses the useDynamicFontSize hook
+ * to ensure consistent scaling across the application.
+ */
+export const AppText = React.memo(AppTextInner);
diff --git a/src/components/mobile/LessonCarousel.tsx b/src/components/mobile/LessonCarousel.tsx
index 20eaf025..c74a3616 100644
--- a/src/components/mobile/LessonCarousel.tsx
+++ b/src/components/mobile/LessonCarousel.tsx
@@ -9,11 +9,11 @@ import {
TouchableOpacity,
View,
} from 'react-native';
-import { LinearGradient } from 'expo-linear-gradient';
+
+import { useDebounceCallback } from '../../hooks';
import { useAnalytics } from '../../hooks/useAnalytics';
import { Lesson, CourseProgress } from '../../types/course';
import { AnalyticsEvent } from '../../utils/trackingEvents';
-import { useDebounceCallback } from '../../hooks';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
@@ -48,7 +48,7 @@ const LessonCarousel = ({
renderLessonContent,
onLastLessonNext,
isLastLessonInSection = false,
-}: LessonCarouselProps) {
+}: LessonCarouselProps) => {
const { trackEvent } = useAnalytics();
const scrollViewRef = useRef(null);
const [currentIndex, setCurrentIndex] = useState(0);
@@ -95,7 +95,7 @@ const LessonCarousel = ({
const debouncedScroll = useDebounceCallback((offsetX: number) => {
const index = Math.round(offsetX / SCREEN_WIDTH);
if (index >= 0 && index < lessons.length) {
- setCurrentIndex((prevIndex) => {
+ setCurrentIndex(prevIndex => {
if (index !== prevIndex) {
const lesson = lessons[index];
onLessonChange(lesson.id, index);
@@ -235,6 +235,7 @@ const LessonCarousel = ({
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={handleMomentumScrollEnd}
+ onScroll={handleScroll}
scrollEventThrottle={16}
decelerationRate="fast"
snapToInterval={SCREEN_WIDTH}
diff --git a/src/components/mobile/MobileCourseViewer.tsx b/src/components/mobile/MobileCourseViewer.tsx
index 5fd27c63..09315185 100644
--- a/src/components/mobile/MobileCourseViewer.tsx
+++ b/src/components/mobile/MobileCourseViewer.tsx
@@ -9,19 +9,20 @@ import {
TouchableOpacity,
View,
} from 'react-native';
-import { AppText as Text } from '../common/AppText';
+import { SafeAreaView } from 'react-native-safe-area-context';
+
+import BookmarkButton from './BookmarkButton';
+import { CourseViewerSkeleton } from './CourseViewerSkeleton';
+import LessonCarousel from './LessonCarousel';
+import MobileSyllabus from './MobileSyllabus';
import { useCourseProgress, useDynamicFontSize } from '../../hooks';
-import { SafeAreaView } from "react-native-safe-area-context";
-import logger from "../../utils/logger";
-import PrimaryButton from "../common/PrimaryButton";
-import BookmarkButton from "./BookmarkButton";
-import LessonCarousel from "./LessonCarousel";
-import MobileSyllabus from "./MobileSyllabus";
-import { useAnalytics } from "../../hooks/useAnalytics";
-import { Course, Lesson, Note } from "../../types/course";
-import { AnalyticsEvent, ScreenName } from "../../utils/trackingEvents";
-import { ErrorBoundary } from "../common/ErrorBoundary";
-import { CourseViewerSkeleton } from "./CourseViewerSkeleton";
+import { useAnalytics } from '../../hooks/useAnalytics';
+import { Course, Lesson, Note } from '../../types/course';
+import { logger } from '../../utils/logger';
+import { AnalyticsEvent, ScreenName } from '../../utils/trackingEvents';
+import { AppText as Text } from '../common/AppText';
+import { ErrorBoundary } from '../common/ErrorBoundary';
+import PrimaryButton from '../common/PrimaryButton';
/**
* Props for the MobileCourseViewer component
@@ -41,13 +42,13 @@ interface MobileCourseViewerProps {
type ViewMode = 'lesson' | 'syllabus' | 'notes';
-export default function MobileCourseViewer({
+const MobileCourseViewer: React.FC = ({
course,
initialLessonId,
initialViewMode,
onBack,
navigation,
-}: MobileCourseViewerProps) {
+}: MobileCourseViewerProps) => {
const { scale } = useDynamicFontSize();
const { trackEvent, trackScreen } = useAnalytics();
const [viewMode, setViewMode] = useState(initialViewMode || 'lesson');
@@ -64,7 +65,6 @@ export default function MobileCourseViewer({
const {
progress,
isLoading,
- updateLessonProgress,
markLessonComplete,
setCurrentLesson,
addBookmark,
@@ -80,8 +80,11 @@ export default function MobileCourseViewer({
autoSync: true,
});
- // Get all lessons in order
- const allLessons = course.sections.flatMap(section => section.lessons.map(lesson => lesson));
+ // Memoized — recalculates only when the sections array reference changes (i.e. new course data).
+ const allLessons = useMemo(
+ () => course.sections.flatMap(section => section.lessons),
+ [course.sections]
+ );
// Helper to get section ID for a lesson
const getSectionIdForLesson = useCallback(
@@ -142,7 +145,7 @@ export default function MobileCourseViewer({
} catch (error) {
logger.error('Error in MobileCourseViewer:', error);
}
- }, [course.id]);
+ }, [course.id, course.title, trackEvent, trackScreen]);
// Track course completion
useEffect(() => {
@@ -341,7 +344,7 @@ export default function MobileCourseViewer({
);
},
- [progress, handleAddNote, handleEditNote, handleDeleteNote]
+ [progress, handleEditNote, handleDeleteNote]
);
if (isLoading) {
@@ -579,7 +582,9 @@ export default function MobileCourseViewer({
);
-}
+};
+
+export default MobileCourseViewer;
const styles = StyleSheet.create({
container: {
diff --git a/src/components/mobile/MobileProfile.tsx b/src/components/mobile/MobileProfile.tsx
index 70d75f93..e873ceb7 100644
--- a/src/components/mobile/MobileProfile.tsx
+++ b/src/components/mobile/MobileProfile.tsx
@@ -17,10 +17,9 @@ import {
Users,
X,
} from 'lucide-react-native';
-import React, { useState } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
- Animated,
LayoutAnimation,
Platform,
SafeAreaView,
@@ -31,17 +30,19 @@ import {
View,
} from 'react-native';
-// Enable LayoutAnimation on Android
-if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
- UIManager.setLayoutAnimationEnabledExperimental(true);
-}
-import { AppText as Text } from '../common/AppText';
-import { CachedImage } from '../ui/CachedImage';
-import { Skeleton } from '../ui/Skeleton';
import { Achievement, AchievementBadges } from './AchievementBadges';
import { AvatarCamera } from './AvatarCamera';
import { MobileFormInput } from './MobileFormInput';
import { StatisticsDisplay } from './StatisticsDisplay';
+import { useUnlockedCount } from '../../store/achievementStore';
+import { AppText as Text } from '../common/AppText';
+import { CachedImage } from '../ui/CachedImage';
+import { Skeleton } from '../ui/Skeleton';
+
+// Enable LayoutAnimation on Android
+if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
+ UIManager.setLayoutAnimationEnabledExperimental(true);
+}
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -106,6 +107,14 @@ interface ProfileData {
type ProfileTab = 'overview' | 'stats' | 'achievements' | 'connections';
+// Stable module-level constant — no dependencies on props or state.
+const TABS: { key: ProfileTab; label: string }[] = [
+ { key: 'overview', label: 'Profile' },
+ { key: 'stats', label: 'Stats' },
+ { key: 'achievements', label: 'Badges' },
+ { key: 'connections', label: 'Network' },
+];
+
// ─── Mock data (replace with API call in production) ─────────────────────────
const MOCK_PROFILE: ProfileData = {
@@ -247,56 +256,13 @@ interface MobileProfileProps {
isLoading?: boolean;
}
-import { useDynamicFontSize } from '../../hooks';
-
-export const MobileProfile: React.FC = ({
+const MobileProfileInner: React.FC = ({
userId: _userId,
isDark = false,
isLoading = false,
}) => {
const [profile, setProfile] = useState(MOCK_PROFILE);
- const { scale } = useDynamicFontSize();
- const { achievements, unlockedCount } = useAchievementStore();
-
- if (isLoading) {
- const bg = isDark ? '#0f172a' : '#f8fafc';
- const cardBg = isDark ? '#1e293b' : '#fff';
- const borderColor = isDark ? '#334155' : '#e2e8f0';
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
+ const unlockedCount = useUnlockedCount();
const [activeTab, setActiveTab] = useState('overview');
const [isEditing, setIsEditing] = useState(false);
const [isCameraVisible, setIsCameraVisible] = useState(false);
@@ -327,31 +293,31 @@ export const MobileProfile: React.FC = ({
.toUpperCase()
.slice(0, 2);
- const handleStartEdit = () => {
+ const handleStartEdit = useCallback(() => {
setEditName(profile.name);
setEditBio(profile.bio);
setEditEmail(profile.email);
setEditLocation(profile.location);
setEditWebsite(profile.website);
setFormErrors({});
- setShowAdvancedFields(false); // reset disclosure state on each edit session
+ setShowAdvancedFields(false);
setIsEditing(true);
- };
+ }, [profile.name, profile.bio, profile.email, profile.location, profile.website]);
- const handleToggleAdvancedFields = () => {
+ const handleToggleAdvancedFields = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setShowAdvancedFields(prev => !prev);
- };
+ }, []);
- const validateForm = (): Record => {
+ const validateForm = useCallback((): Record => {
const errors: Record = {};
if (!editName.trim()) errors.name = 'Name is required';
if (!editEmail.trim()) errors.email = 'Email is required';
else if (!/\S+@\S+\.\S+/.test(editEmail)) errors.email = 'Enter a valid email address';
return errors;
- };
+ }, [editName, editEmail]);
- const handleSave = async () => {
+ const handleSave = useCallback(async () => {
const errors = validateForm();
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
@@ -370,64 +336,110 @@ export const MobileProfile: React.FC = ({
}));
setIsSaving(false);
setIsEditing(false);
- };
+ }, [validateForm, editName, editBio, editEmail, editLocation, editWebsite]);
- const handleCancelEdit = () => {
+ const handleCancelEdit = useCallback(() => {
setIsEditing(false);
setFormErrors({});
- };
+ }, []);
- const handleAvatarConfirm = (uri: string) => {
+ const handleAvatarConfirm = useCallback((uri: string) => {
setProfile(prev => ({ ...prev, avatar: uri }));
- };
+ }, []);
- const handleToggleFollow = (connectionId: string) => {
+ const handleCloseCamera = useCallback(() => setIsCameraVisible(false), []);
+
+ const handleToggleFollow = useCallback((connectionId: string) => {
setProfile(prev => ({
...prev,
connections: prev.connections.map(c =>
c.id === connectionId ? { ...c, isFollowing: !c.isFollowing } : c
),
}));
- };
+ }, []);
- // Tab config
- const TABS: { key: ProfileTab; label: string }[] = [
- { key: 'overview', label: 'Profile' },
- { key: 'stats', label: 'Stats' },
- { key: 'achievements', label: 'Badges' },
- { key: 'connections', label: 'Network' },
- ];
+ const statsForDisplay = useMemo(
+ () => [
+ { label: 'Courses Done', value: profile.stats.coursesCompleted },
+ { label: 'Enrolled', value: profile.stats.coursesEnrolled },
+ { label: 'Hours', value: profile.stats.totalHours },
+ { label: 'Day Streak', value: `${profile.stats.streak} 🔥` },
+ ],
+ [
+ profile.stats.coursesCompleted,
+ profile.stats.coursesEnrolled,
+ profile.stats.totalHours,
+ profile.stats.streak,
+ ]
+ );
- const statsForDisplay = [
- { label: 'Courses Done', value: profile.stats.coursesCompleted },
- { label: 'Enrolled', value: profile.stats.coursesEnrolled },
- { label: 'Hours', value: profile.stats.totalHours },
- { label: 'Day Streak', value: `${profile.stats.streak} 🔥` },
- ];
+ // stripItems holds JSX elements — memoize to avoid recreating React nodes on every render.
+ const stripItems = useMemo(
+ () => [
+ {
+ icon: ,
+ value: profile.stats.coursesCompleted,
+ label: 'Done',
+ },
+ {
+ icon: ,
+ value: profile.stats.connections,
+ label: 'Network',
+ },
+ {
+ icon: ,
+ value: unlockedCount,
+ label: 'Badges',
+ },
+ {
+ icon: ,
+ value: `${profile.stats.totalHours}h`,
+ label: 'Learning',
+ },
+ ],
+ [
+ profile.stats.coursesCompleted,
+ profile.stats.connections,
+ profile.stats.totalHours,
+ unlockedCount,
+ ]
+ );
- // ─── Header strip items ───────────────────────────────────────────────────
- const stripItems = [
- {
- icon: ,
- value: profile.stats.coursesCompleted,
- label: 'Done',
- },
- {
- icon: ,
- value: profile.stats.connections,
- label: 'Network',
- },
- {
- icon: ,
- value: unlockedCount,
- label: 'Badges',
- },
- {
- icon: ,
- value: `${profile.stats.totalHours}h`,
- label: 'Learning',
- },
- ];
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
return (
@@ -656,15 +668,19 @@ export const MobileProfile: React.FC = ({
onPress={handleToggleAdvancedFields}
activeOpacity={0.7}
accessibilityRole="button"
- accessibilityLabel={showAdvancedFields ? 'Hide advanced details' : 'Show advanced details'}
+ accessibilityLabel={
+ showAdvancedFields ? 'Hide advanced details' : 'Show advanced details'
+ }
accessibilityState={{ expanded: showAdvancedFields }}
>
{showAdvancedFields ? 'Hide Advanced Details' : 'Advanced Details'}
- {showAdvancedFields
- ?
- : }
+ {showAdvancedFields ? (
+
+ ) : (
+
+ )}
{/* ── Advanced Fields (expandable) ── */}
@@ -746,7 +762,7 @@ export const MobileProfile: React.FC = ({
🔥
{profile.stats.streak} Day Streak
- Keep it up! You're on fire.
+ {"Keep it up! You're on fire."}
@@ -853,12 +869,14 @@ export const MobileProfile: React.FC = ({
visible={isCameraVisible}
currentAvatar={profile.avatar}
onConfirm={handleAvatarConfirm}
- onClose={() => setIsCameraVisible(false)}
+ onClose={handleCloseCamera}
/>
);
};
+export const MobileProfile = React.memo(MobileProfileInner);
+
// ─── Styles ───────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
diff --git a/src/components/mobile/MobileSearch.tsx b/src/components/mobile/MobileSearch.tsx
index b6a8608e..d924752e 100644
--- a/src/components/mobile/MobileSearch.tsx
+++ b/src/components/mobile/MobileSearch.tsx
@@ -1,5 +1,5 @@
import { AlertCircle, Search, SlidersHorizontal } from 'lucide-react-native';
-import React, { useCallback, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
FlatList,
KeyboardAvoidingView,
@@ -106,7 +106,7 @@ export interface MobileSearchProps {
placeholder?: string;
}
-export const MobileSearch = ({
+const MobileSearchInner = ({
onResultPress,
placeholder = 'Search courses...',
}: MobileSearchProps) => {
@@ -200,6 +200,32 @@ export const MobileSearch = ({
setFilterSheetVisible(false);
}, []);
+ const handleQueryChange = useCallback((text: string) => {
+ setQuery(text);
+ setQueryError(null);
+ }, []);
+
+ const handleFocus = useCallback(() => setSuggestionsVisible(true), []);
+
+ // Delay hiding so a tap on a suggestion registers before the list unmounts.
+ const blurTimer = useRef>();
+ useEffect(() => () => clearTimeout(blurTimer.current), []);
+ const handleBlur = useCallback(() => {
+ blurTimer.current = setTimeout(() => setSuggestionsVisible(false), 180);
+ }, []);
+
+ const handleOpenFilters = useCallback(() => setFilterSheetVisible(true), []);
+ const handleCloseFilters = useCallback(() => setFilterSheetVisible(false), []);
+ const handleResetFilters = useCallback(() => setFilterValues({}), []);
+
+ // Stable renderItem — a new inline arrow would defeat React.memo on SearchResultCard.
+ const renderItem = useCallback(
+ ({ item }: { item: SearchResultItem }) => (
+ onResultPress?.(item)} />
+ ),
+ [onResultPress]
+ );
+
const showSuggestions = suggestionsVisible && query.length > 0;
const showHistory = suggestionsVisible && !query.trim();
const showResults = hasSearched;
@@ -218,12 +244,9 @@ export const MobileSearch = ({
placeholder={placeholder}
placeholderTextColor="#9CA3AF"
value={query}
- onChangeText={text => {
- setQuery(text);
- setQueryError(null);
- }}
- onFocus={() => setSuggestionsVisible(true)}
- onBlur={() => setTimeout(() => setSuggestionsVisible(false), 180)}
+ onChangeText={handleQueryChange}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
onSubmitEditing={handleSubmit}
returnKeyType="search"
/>
@@ -231,7 +254,7 @@ export const MobileSearch = ({
setFilterSheetVisible(true)}
+ onPress={handleOpenFilters}
style={[
styles.filterBtn,
Object.keys(filterValues).length > 0 && styles.filterBtnActive,
@@ -284,30 +307,29 @@ export const MobileSearch = ({
item.id}
- renderItem={({ item }) => (
- onResultPress?.(item)} />
- )}
+ renderItem={renderItem}
contentContainerStyle={styles.resultsList}
- ListEmptyComponent={
- Try a different query or adjust filters.
- }
+ ListEmptyComponent={EMPTY_LIST_COMPONENT}
/>
)}
setFilterSheetVisible(false)}
+ onClose={handleCloseFilters}
filters={DEFAULT_FILTERS}
values={filterValues}
onApply={handleApplyFilters}
- onReset={() => setFilterValues({})}
+ onReset={handleResetFilters}
/>
);
};
+export const MobileSearch = React.memo(MobileSearchInner);
+
const styles = StyleSheet.create({
+ // NOTE: EMPTY_LIST_COMPONENT is defined after styles so it can reference styles.emptyText.
container: {
flex: 1,
backgroundColor: '#F9FAFB',
@@ -414,3 +436,10 @@ const styles = StyleSheet.create({
fontWeight: '500',
},
});
+
+// Defined after `styles` so styles.emptyText is in scope.
+// A module-level constant avoids creating a new element reference on every render,
+// which would prevent FlatList from short-circuiting ListEmptyComponent diffing.
+const EMPTY_LIST_COMPONENT = (
+ Try a different query or adjust filters.
+);
diff --git a/src/components/mobile/SearchResultCard.tsx b/src/components/mobile/SearchResultCard.tsx
index 273d6823..0c88b6e9 100644
--- a/src/components/mobile/SearchResultCard.tsx
+++ b/src/components/mobile/SearchResultCard.tsx
@@ -1,6 +1,6 @@
+import { BookOpen, Clock } from 'lucide-react-native';
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
-import { BookOpen, Clock } from 'lucide-react-native';
export interface SearchResultItem {
id: string;
@@ -19,7 +19,7 @@ export interface SearchResultCardProps {
onPress: () => void;
}
-export function SearchResultCard({ item, onPress }: SearchResultCardProps) {
+const SearchResultCardInner: React.FC = ({ item, onPress }) => {
const metaParts = [item.category, item.level].filter(Boolean);
const metaText = metaParts.join(' · ');
const screenReaderDescription = [item.title, item.description || item.subtitle, metaText]
@@ -59,7 +59,9 @@ export function SearchResultCard({ item, onPress }: SearchResultCardProps) {
);
-}
+};
+
+export const SearchResultCard = React.memo(SearchResultCardInner);
const styles = StyleSheet.create({
card: {
diff --git a/src/store/achievementStore.ts b/src/store/achievementStore.ts
index 9625f2a6..da5edacf 100644
--- a/src/store/achievementStore.ts
+++ b/src/store/achievementStore.ts
@@ -53,7 +53,7 @@ interface AchievementState {
achievementProgress: Record;
/** Number of unlocked achievements */
unlockedCount: number;
-
+
// Actions
/** Unlock an achievement by ID */
unlockAchievement: (id: string) => void;
@@ -151,11 +151,13 @@ export const DEFAULT_ACHIEVEMENTS: Achievement[] = [
];
const DEFAULT_ACHIEVEMENT_BY_ID = Object.fromEntries(
- DEFAULT_ACHIEVEMENTS.map((achievement) => [achievement.id, achievement]),
+ DEFAULT_ACHIEVEMENTS.map(achievement => [achievement.id, achievement])
) as Record;
-function buildAchievementsFromProgress(progressById: Record): Achievement[] {
- return DEFAULT_ACHIEVEMENTS.map((achievement) => {
+function buildAchievementsFromProgress(
+ progressById: Record
+): Achievement[] {
+ return DEFAULT_ACHIEVEMENTS.map(achievement => {
const progress = progressById[achievement.id];
if (!progress) {
return achievement;
@@ -170,7 +172,9 @@ function buildAchievementsFromProgress(progressById: Record {
+function snapshotAchievementProgress(
+ achievements: Achievement[]
+): Record {
return achievements.reduce>((snapshot, achievement) => {
const defaultAchievement = DEFAULT_ACHIEVEMENT_BY_ID[achievement.id];
if (!defaultAchievement) {
@@ -195,7 +199,7 @@ function snapshotAchievementProgress(achievements: Achievement[]): Record>((snapshot, [id, entry]) => {
- if (!isRecord(entry)) {
- return snapshot;
- }
+ return Object.entries(value).reduce>(
+ (snapshot, [id, entry]) => {
+ if (!isRecord(entry)) {
+ return snapshot;
+ }
- const progress: AchievementProgress = {};
+ const progress: AchievementProgress = {};
- if (typeof entry.isLocked === 'boolean') {
- progress.isLocked = entry.isLocked;
- }
+ if (typeof entry.isLocked === 'boolean') {
+ progress.isLocked = entry.isLocked;
+ }
- if (typeof entry.unlockedAt === 'string') {
- progress.unlockedAt = entry.unlockedAt;
- }
+ if (typeof entry.unlockedAt === 'string') {
+ progress.unlockedAt = entry.unlockedAt;
+ }
- if (isRecord(entry.progress)) {
- const current = entry.progress.current;
- const total = entry.progress.total;
- if (typeof current === 'number' && typeof total === 'number') {
- progress.progress = { current, total };
+ if (isRecord(entry.progress)) {
+ const current = entry.progress.current;
+ const total = entry.progress.total;
+ if (typeof current === 'number' && typeof total === 'number') {
+ progress.progress = { current, total };
+ }
}
- }
- if (Object.keys(progress).length > 0) {
- snapshot[id] = progress;
- }
+ if (Object.keys(progress).length > 0) {
+ snapshot[id] = progress;
+ }
- return snapshot;
- }, {});
+ return snapshot;
+ },
+ {}
+ );
}
function normalizeAchievementState(rawState: unknown): {
@@ -265,7 +272,7 @@ function normalizeAchievementState(rawState: unknown): {
const unlockedCount =
typeof persistedState.unlockedCount === 'number'
? persistedState.unlockedCount
- : achievements.filter((achievement) => !achievement.isLocked).length;
+ : achievements.filter(achievement => !achievement.isLocked).length;
return {
achievements,
@@ -274,6 +281,16 @@ function normalizeAchievementState(rawState: unknown): {
};
}
+// ─── Granular selector hooks ─────────────────────────────────────────────────
+// Each hook re-renders its consumer only when that specific slice changes.
+// Always prefer these over calling useAchievementStore() without a selector.
+
+/** Subscribes only to the achievements array. */
+export const useAchievements = () => useAchievementStore(state => state.achievements);
+
+/** Subscribes only to the unlocked badge count. */
+export const useUnlockedCount = () => useAchievementStore(state => state.unlockedCount);
+
export const useAchievementStore = create()(
persist(
(set, get) => ({
@@ -282,11 +299,11 @@ export const useAchievementStore = create()(
unlockedCount: 0,
unlockAchievement: (id: string) =>
- set((state) => {
- const achievement = state.achievements.find((a) => a.id === id);
+ set(state => {
+ const achievement = state.achievements.find(a => a.id === id);
if (!achievement || !achievement.isLocked) return state;
- const updatedAchievements = state.achievements.map((a) =>
+ const updatedAchievements = state.achievements.map(a =>
a.id === id
? {
...a,
@@ -302,20 +319,20 @@ export const useAchievementStore = create()(
return {
achievements: updatedAchievements,
achievementProgress: snapshotAchievementProgress(updatedAchievements),
- unlockedCount: updatedAchievements.filter((a) => !a.isLocked).length,
+ unlockedCount: updatedAchievements.filter(a => !a.isLocked).length,
};
}),
updateProgress: (id: string, current: number) =>
- set((state) => {
- const achievement = state.achievements.find((a) => a.id === id);
+ set(state => {
+ const achievement = state.achievements.find(a => a.id === id);
if (!achievement || !achievement.isLocked) return state;
- const updatedAchievements = state.achievements.map((a) => {
+ const updatedAchievements = state.achievements.map(a => {
if (a.id !== id) return a;
const progress = a.progress ? { ...a.progress, current } : { current, total: 1 };
-
+
// Auto-unlock if progress is complete
if (progress.current >= progress.total) {
return {
@@ -335,17 +352,17 @@ export const useAchievementStore = create()(
return {
achievements: updatedAchievements,
achievementProgress: snapshotAchievementProgress(updatedAchievements),
- unlockedCount: updatedAchievements.filter((a) => !a.isLocked).length,
+ unlockedCount: updatedAchievements.filter(a => !a.isLocked).length,
};
}),
isAchievementUnlocked: (id: string) => {
- const achievement = get().achievements.find((a) => a.id === id);
+ const achievement = get().achievements.find(a => a.id === id);
return achievement ? !achievement.isLocked : false;
},
getUnlockedAchievements: () => {
- return get().achievements.filter((a) => !a.isLocked);
+ return get().achievements.filter(a => !a.isLocked);
},
resetAchievements: () =>
@@ -359,18 +376,18 @@ export const useAchievementStore = create()(
set({
achievements,
achievementProgress: snapshotAchievementProgress(achievements),
- unlockedCount: achievements.filter((a) => !a.isLocked).length,
+ unlockedCount: achievements.filter(a => !a.isLocked).length,
}),
}),
{
name: 'achievement-storage',
version: 1,
storage: asyncStorageJSONStorage,
- partialize: (state) => ({
+ partialize: state => ({
achievementProgress: state.achievementProgress,
unlockedCount: state.unlockedCount,
}),
- migrate: (persistedState) => normalizeAchievementState(persistedState),
+ migrate: persistedState => normalizeAchievementState(persistedState),
merge: (persistedState, currentState) => {
const normalizedState = normalizeAchievementState(persistedState);
return {
@@ -378,6 +395,6 @@ export const useAchievementStore = create()(
...normalizedState,
};
},
- },
+ }
)
);