diff --git a/.rnstorybook/preview.tsx b/.rnstorybook/preview.tsx index 0a973510..69ec1b40 100644 --- a/.rnstorybook/preview.tsx +++ b/.rnstorybook/preview.tsx @@ -17,7 +17,7 @@ const preview: Preview = { }, }, decorators: [ - (Story) => ( + Story => ( diff --git a/.rnstorybook/stories/Button.stories.tsx b/.rnstorybook/stories/Button.stories.tsx index e14a5cc5..9b277dc7 100644 --- a/.rnstorybook/stories/Button.stories.tsx +++ b/.rnstorybook/stories/Button.stories.tsx @@ -9,7 +9,7 @@ const meta = { title: 'Example/Button', component: Button, decorators: [ - (Story) => ( + Story => ( diff --git a/App.tsx b/App.tsx index e4e19787..4a8a79b5 100644 --- a/App.tsx +++ b/App.tsx @@ -11,6 +11,7 @@ import { Text, View, } from 'react-native'; + import StorybookUI from './.rnstorybook'; import './global.css'; import { ErrorBoundary } from './src/components/common/ErrorBoundary'; @@ -25,7 +26,7 @@ import { subscribeToCacheStatus, } from './src/services/api'; import { warmCriticalCaches } from './src/services/cacheWarming'; -import { crashReportingService } from './src/services/cashReporting'; +import { crashReportingService } from './src/services/crashReporting'; import { featureCapabilities } from './src/services/featureCapabilities'; import { inAppReviewService } from './src/services/inAppReview'; import { mobileAuthService } from './src/services/mobileAuth'; @@ -37,12 +38,12 @@ import { removeNotificationListener, } from './src/services/pushNotifications'; import { requestQueue } from './src/services/requestQueue'; +import { searchIndexService } from './src/services/searchIndex'; import { initializeSecureStorage } from './src/services/secureStorage'; // Added missing storage helper mock path import socketService from './src/services/socket'; import { syncService } from './src/services/syncService'; // Fixed naming convention from the merge conflict import { useAppStore, useNotificationStore } from './src/store'; // Added missing store imports import { useDegradationStore } from './src/store/degradationStore'; -import { searchIndexService } from './src/services/searchIndex'; import { handleCacheVersionUpdate } from './src/utils/cacheVersioning'; import { requireEnvVariables } from './src/utils/env'; import { appLogger } from './src/utils/logger'; @@ -217,6 +218,11 @@ const App = () => { } }); + // Notification listener subscription + const subscription = addNotificationReceivedListener(notification => + handleNotificationReceived(notification) + ); + // ===== DEFERRED PATH — runs after user interactions complete ===== // These tasks are non-critical: they enhance the experience but are not // needed for the initial render or core feature set. Scheduling them @@ -278,7 +284,6 @@ const App = () => { return () => { socketService.disconnect(); syncService.stopAutoSync(); - notificationCleanup(); removeNotificationListener(subscription); // @ts-ignore global.onunhandledrejection = undefined; diff --git a/README.md b/README.md index c3043f50..71ad8e5d 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ import { retrieveLogFiles, clearLogFiles } from '@/config/logging'; // Get all stored logs const logs = await retrieveLogFiles(); -logs.forEach((log) => console.log(log)); +logs.forEach(log => console.log(log)); // Clear log storage await clearLogFiles(); @@ -165,7 +165,6 @@ Copy `.env.example` to `.env` and set the following: | `EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS` | No | Enable push notifications (`true` / `false`) | | `EXPO_PUBLIC_STORYBOOK` | No | Enable Storybook mode (`true` / `false`) | - The app validates `EXPO_PUBLIC_API_BASE_URL` and `EXPO_PUBLIC_SOCKET_URL` at startup and will refuse to launch with invalid or missing values. For EAS builds, secrets are configured per build profile in `eas.json` rather than `.env`. @@ -229,11 +228,11 @@ Run `eas credentials` to set up or repair iOS/Android signing credentials. The project defines three EAS build profiles in `eas.json`: -| Profile | Description | Usage | -|---|---|---| -| **development** | Fast internal build for debugging, includes development client and runs on the `development` channel. | `eas build --profile development` | -| **preview** | Internal preview build, generates an APK for Android, runs on the `preview` channel. | `eas build --profile preview` | -| **production** | Production‑ready build with auto‑incremented version numbers for iOS and Android, publishes to the `production` channel. | `eas build --profile production` | +| Profile | Description | Usage | +| --------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------- | +| **development** | Fast internal build for debugging, includes development client and runs on the `development` channel. | `eas build --profile development` | +| **preview** | Internal preview build, generates an APK for Android, runs on the `preview` channel. | `eas build --profile preview` | +| **production** | Production‑ready build with auto‑incremented version numbers for iOS and Android, publishes to the `production` channel. | `eas build --profile production` | These profiles can also be used when submitting: @@ -241,7 +240,6 @@ These profiles can also be used when submitting: Refer to the official EAS docs for more details. - --- ## 🚀 Deployment @@ -285,4 +283,4 @@ EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS=true > ⚠️ Never commit your `.env` file. It is listed in `.gitignore`. -See [DEPLOY.md](./DEPLOY.md) for platform-specific setup (Google Play & App Store), build profiles, troubleshooting, and security notes. \ No newline at end of file +See [DEPLOY.md](./DEPLOY.md) for platform-specific setup (Google Play & App Store), build profiles, troubleshooting, and security notes. diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 6990669d..09c29fe4 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -7,6 +7,22 @@ import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors } from '@/constants/theme'; import { useTheme } from '@/store'; +const HomeTabIcon = ({ color }: { color: string }) => ( + +); + +const SearchTabIcon = ({ color }: { color: string }) => ( + +); + +const ProfileTabIcon = ({ color }: { color: string }) => ( + +); + +const DashboardTabIcon = ({ color }: { color: string }) => ( + +); + const TabLayout = () => { const theme = useTheme(); @@ -25,32 +41,28 @@ const TabLayout = () => { name="index" options={{ title: 'Home', - tabBarIcon: ({ color }) => , + tabBarIcon: HomeTabIcon, }} /> ( - - ), + tabBarIcon: SearchTabIcon, }} /> , + tabBarIcon: ProfileTabIcon, }} /> ( - - ), + tabBarIcon: DashboardTabIcon, }} /> diff --git a/app/(tabs)/dashboard.tsx b/app/(tabs)/dashboard.tsx index babecc0d..5e055c19 100644 --- a/app/(tabs)/dashboard.tsx +++ b/app/(tabs)/dashboard.tsx @@ -14,7 +14,7 @@ import { ScreenName } from '@/utils/trackingEvents'; const LazyTeamDashboard = createLazyRoute({ importFn: () => - import('@/components/mobile/TeamDashboard/TeamDashboard').then((m) => ({ + import('@/components/mobile/TeamDashboard/TeamDashboard').then(m => ({ default: m.TeamDashboard, })), LoadingFallback: DashboardSkeleton, @@ -23,7 +23,7 @@ const LazyTeamDashboard = createLazyRoute({ const DashboardTab = () => { const { trackScreen } = useAnalytics(); - const theme = useAppStore((s) => s.theme); + const theme = useAppStore(s => s.theme); useEffect(() => { trackScreen(ScreenName.HOME, { tab: 'dashboard' }); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 9c15764d..408b2a43 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -9,7 +9,7 @@ import { ScreenName } from '@/utils/trackingEvents'; const LazyHomeScreenContent = createLazyRoute({ importFn: () => - import('@/screens/HomeScreenContent').then((m) => ({ default: m.HomeScreenContent })), + import('@/screens/HomeScreenContent').then(m => ({ default: m.HomeScreenContent })), LoadingFallback: HomeScreenSkeleton, boundaryName: 'HomeRoute', }); @@ -51,7 +51,7 @@ const HomeScreen = () => { useEffect(() => { const cleanup = fetchHomeData(); return cleanup; - // eslint-disable-next-line react-hooks/exhaustive-deps -- simulated home fetch runs once on mount + // eslint-disable-next-line react-hooks/exhaustive-deps -- simulated home fetch runs once on mount }, []); if (isLoading) { diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 6a78fb7e..2fbc80ea 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -6,14 +6,14 @@ import { createLazyRoute } from '@/utils/lazyRoute'; const LazyMobileProfile = createLazyRoute({ importFn: () => - import('@/components/mobile/MobileProfile').then((m) => ({ default: m.MobileProfile })), + import('@/components/mobile/MobileProfile').then(m => ({ default: m.MobileProfile })), LoadingFallback: ProfileSkeleton, boundaryName: 'ProfileTabRoute', }); const ProfileTab = () => { - const theme = useAppStore((s) => s.theme); - const user = useAppStore((s) => s.user); + const theme = useAppStore(s => s.theme); + const user = useAppStore(s => s.user); const [isLoading, setIsLoading] = useState(true); useEffect(() => { @@ -23,9 +23,7 @@ const ProfileTab = () => { const userId = user?.id ?? '123'; - return ( - - ); + return ; }; export default ProfileTab; diff --git a/app/(tabs)/search.tsx b/app/(tabs)/search.tsx index fc77bc2a..aa378dc0 100644 --- a/app/(tabs)/search.tsx +++ b/app/(tabs)/search.tsx @@ -2,18 +2,13 @@ import { useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; import { Alert, StyleSheet, View } from 'react-native'; -import { - CourseCardSkeleton, - SearchResultItem, - SearchScreenSkeleton, - Skeleton, -} from '@/components'; +import { CourseCardSkeleton, SearchResultItem, SearchScreenSkeleton, Skeleton } from '@/components'; import { sampleCourse } from '@/data/sampleCourse'; import { createLazyRoute } from '@/utils/lazyRoute'; const LazyMobileSearch = createLazyRoute({ importFn: () => - import('@/components/mobile/MobileSearch').then((m) => ({ default: m.MobileSearch })), + import('@/components/mobile/MobileSearch').then(m => ({ default: m.MobileSearch })), LoadingFallback: SearchScreenSkeleton, boundaryName: 'SearchRoute', }); @@ -46,7 +41,7 @@ const SearchTab = () => { useEffect(() => { const cleanup = fetchSearchData(); return cleanup; - // eslint-disable-next-line react-hooks/exhaustive-deps -- simulated search fetch runs once on mount + // eslint-disable-next-line react-hooks/exhaustive-deps -- simulated search fetch runs once on mount }, []); const handleResultPress = (item: SearchResultItem) => { diff --git a/app/+html.tsx b/app/+html.tsx index eca202ae..a6fc0a2e 100644 --- a/app/+html.tsx +++ b/app/+html.tsx @@ -7,14 +7,14 @@ type RootHtmlProps = { children: React.ReactNode; }; -export default function RootHtml({ children }: RootHtmlProps) { - return ( - - - - ); -} +}; /** * Video player loading skeleton */ -export function VideoPlayerSkeleton() { +export const VideoPlayerSkeleton = () => { return (
@@ -77,12 +77,12 @@ export function VideoPlayerSkeleton() {
); -} +}; /** * Data grid loading skeleton */ -export function DataGridSkeleton() { +export const DataGridSkeleton = () => { return (
@@ -95,12 +95,12 @@ export function DataGridSkeleton() { ))}
); -} +}; /** * Profile card loading skeleton */ -export function ProfileSkeleton() { +export const ProfileSkeleton = () => { return (
{/* Avatar */} @@ -141,12 +141,12 @@ export function ProfileSkeleton() { `}
); -} +}; /** * Settings page loading skeleton */ -export function SettingsSkeleton() { +export const SettingsSkeleton = () => { return (
{Array.from({ length: 6 }).map((_, i) => ( @@ -159,12 +159,12 @@ export function SettingsSkeleton() { ))}
); -} +}; /** * Quiz card loading skeleton */ -export function QuizSkeleton() { +export const QuizSkeleton = () => { return (
{/* Question */} @@ -183,12 +183,12 @@ export function QuizSkeleton() {
); -} +}; /** * Search results loading skeleton */ -export function SearchResultsSkeleton() { +export const SearchResultsSkeleton = () => { return (
{/* Search bar */} @@ -221,12 +221,12 @@ export function SearchResultsSkeleton() { `}
); -} +}; /** * Download queue loading skeleton */ -export function DownloadQueueSkeleton() { +export const DownloadQueueSkeleton = () => { return (
{Array.from({ length: 3 }).map((_, i) => ( @@ -242,12 +242,12 @@ export function DownloadQueueSkeleton() { ))}
); -} +}; /** * Generic card loading skeleton */ -export function CardSkeleton({ count = 3 }: { count?: number }) { +export const CardSkeleton = ({ count = 3 }: { count?: number }) => { return (
{Array.from({ length: count }).map((_, i) => ( @@ -260,12 +260,12 @@ export function CardSkeleton({ count = 3 }: { count?: number }) { ))}
); -} +}; /** * Course content loading skeleton */ -export function CourseContentSkeleton() { +export const CourseContentSkeleton = () => { return (
{/* Syllabus/Tabs */} @@ -286,4 +286,4 @@ export function CourseContentSkeleton() { ))}
); -} +}; diff --git a/src/components/mobile/AchievementBadges.tsx b/src/components/mobile/AchievementBadges.tsx index 2bdaf425..a79cf483 100644 --- a/src/components/mobile/AchievementBadges.tsx +++ b/src/components/mobile/AchievementBadges.tsx @@ -66,7 +66,7 @@ const RARITY_LABELS: Record = { legendary: 'Legendary', }; -export const AchievementBadges: React.FC = React.memo(({ +const AchievementBadgesRaw: React.FC = ({ achievements, isDark = false, }) => { @@ -335,7 +335,10 @@ export const AchievementBadges: React.FC = React.memo(({ ); -}); +}; + +export const AchievementBadges: React.FC = React.memo(AchievementBadgesRaw); +AchievementBadges.displayName = 'AchievementBadges'; const styles = StyleSheet.create({ container: { diff --git a/src/components/mobile/BookmarkList.tsx b/src/components/mobile/BookmarkList.tsx index 1b60430a..a19ec9c4 100644 --- a/src/components/mobile/BookmarkList.tsx +++ b/src/components/mobile/BookmarkList.tsx @@ -82,31 +82,8 @@ export const BookmarkList = () => { renderItem={renderItem} keyExtractor={item => item.itemId} contentContainerStyle={styles.list} + removeClippedSubviews={true} /> - - {bookmarks.map(item => ( - removeBookmark(item.itemId)} - onArchive={() => handleArchive(item.itemId)} - deleteLabel="Delete" - archiveLabel="Archive" - > - router.push(item.url as any)} - activeOpacity={0.75} - accessibilityRole="link" - accessibilityLabel={item.title} - > - {item.title} - {item.itemType} - - - ))} - ); }; diff --git a/src/components/mobile/ConnectionManager.tsx b/src/components/mobile/ConnectionManager.tsx index 895137eb..431b91bc 100644 --- a/src/components/mobile/ConnectionManager.tsx +++ b/src/components/mobile/ConnectionManager.tsx @@ -31,24 +31,26 @@ export const ConnectionManager: React.FC = ({ const renderConnectionItem = useCallback( ({ item }: ListRenderItemInfo) => ( - - - {/* Placeholder for Avatar */} - - {item.name.charAt(0)} + + + {/* Placeholder for Avatar */} + + {item.name.charAt(0)} + + {item.name} - {item.name} + {onRemoveConnection && ( + onRemoveConnection(item.id)} + > + Remove + + )} - {onRemoveConnection && ( - onRemoveConnection(item.id)} - > - Remove - - )} - - ), [onRemoveConnection]); + ), + [onRemoveConnection] + ); return ( diff --git a/src/components/mobile/CourseCardSkeleton.tsx b/src/components/mobile/CourseCardSkeleton.tsx index e94d8f8c..7fd02b07 100644 --- a/src/components/mobile/CourseCardSkeleton.tsx +++ b/src/components/mobile/CourseCardSkeleton.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { StyleSheet, View } from 'react-native'; + import { Skeleton } from '../ui/Skeleton'; export const CourseCardSkeleton = () => { diff --git a/src/components/mobile/HealthDashboard/AlertBanner.tsx b/src/components/mobile/HealthDashboard/AlertBanner.tsx index dce2006b..248584b5 100644 --- a/src/components/mobile/HealthDashboard/AlertBanner.tsx +++ b/src/components/mobile/HealthDashboard/AlertBanner.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + import type { MetricAlert } from '../../../services/healthMetrics'; // ─── Types ──────────────────────────────────────────────────────────────────── diff --git a/src/components/mobile/HealthDashboard/DashboardHeader.tsx b/src/components/mobile/HealthDashboard/DashboardHeader.tsx index 382ca8e5..e7db6add 100644 --- a/src/components/mobile/HealthDashboard/DashboardHeader.tsx +++ b/src/components/mobile/HealthDashboard/DashboardHeader.tsx @@ -6,13 +6,7 @@ */ import React from 'react'; -import { - ActivityIndicator, - StyleSheet, - Text, - TouchableOpacity, - View, -} from 'react-native'; +import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; type OverallStatus = 'ok' | 'warning' | 'critical'; diff --git a/src/components/mobile/HealthDashboard/HealthDashboard.tsx b/src/components/mobile/HealthDashboard/HealthDashboard.tsx index 8ef4a32d..8eeae57b 100644 --- a/src/components/mobile/HealthDashboard/HealthDashboard.tsx +++ b/src/components/mobile/HealthDashboard/HealthDashboard.tsx @@ -9,22 +9,17 @@ */ import React from 'react'; -import { - RefreshControl, - ScrollView, - StyleSheet, - Text, - View, -} from 'react-native'; +import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useHealthDashboard } from '../../../hooks/useHealthDashboard'; -import type { AlertSeverity, HealthSnapshot } from '../../../services/healthMetrics'; import { AlertBanner } from './AlertBanner'; import { DashboardHeader } from './DashboardHeader'; import { LatencyBar } from './LatencyBar'; import { MetricCard } from './MetricCard'; import { ThresholdEditor } from './ThresholdEditor'; +import { useHealthDashboard } from '../../../hooks/useHealthDashboard'; + +import type { AlertSeverity, HealthSnapshot } from '../../../services/healthMetrics'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -42,9 +37,7 @@ function latencySeverity(p95: number, warn: number, crit: number): AlertSeverity // ─── Skeleton ───────────────────────────────────────────────────────────────── -const SkeletonCard: React.FC = () => ( - -); +const SkeletonCard: React.FC = () => ; const LoadingSkeleton: React.FC = () => ( @@ -102,7 +95,7 @@ export const HealthDashboard: React.FC = () => { {alerts.length > 0 && ( Active Alerts ({alerts.length}) - {alerts.map((alert) => ( + {alerts.map(alert => ( ))} @@ -117,7 +110,11 @@ export const HealthDashboard: React.FC = () => { value={snap.crashRate} unit="%" subValue={`${snap.crashCount} crashes`} - severity={crashSeverity(snap.crashRate, thresholds.crashRateWarning, thresholds.crashRateCritical)} + severity={crashSeverity( + snap.crashRate, + thresholds.crashRateWarning, + thresholds.crashRateCritical + )} icon="💥" /> { snap.errorRatePerMinute >= thresholds.errorRateCritical ? 'critical' : snap.errorRatePerMinute >= thresholds.errorRateWarning - ? 'warning' - : 'ok' + ? 'warning' + : 'ok' } icon="⚠️" /> @@ -163,8 +160,8 @@ export const HealthDashboard: React.FC = () => { snap.apiErrorRate >= thresholds.apiErrorRateCritical ? 'critical' : snap.apiErrorRate >= thresholds.apiErrorRateWarning - ? 'warning' - : 'ok' + ? 'warning' + : 'ok' } icon="🔴" /> @@ -208,11 +205,7 @@ export const HealthDashboard: React.FC = () => { unit="%" subValue="thread load" severity={ - snap.jsBusyRatio > 0.8 - ? 'critical' - : snap.jsBusyRatio > 0.5 - ? 'warning' - : 'ok' + snap.jsBusyRatio > 0.8 ? 'critical' : snap.jsBusyRatio > 0.5 ? 'warning' : 'ok' } icon="⚙️" /> diff --git a/src/components/mobile/HealthDashboard/LatencyBar.tsx b/src/components/mobile/HealthDashboard/LatencyBar.tsx index a528640c..bee73382 100644 --- a/src/components/mobile/HealthDashboard/LatencyBar.tsx +++ b/src/components/mobile/HealthDashboard/LatencyBar.tsx @@ -33,7 +33,11 @@ const LatencyRow: React.FC<{ const color = barColor(value, warning, critical); return ( - + {label} diff --git a/src/components/mobile/HealthDashboard/MetricCard.tsx b/src/components/mobile/HealthDashboard/MetricCard.tsx index e466751a..fa19eef8 100644 --- a/src/components/mobile/HealthDashboard/MetricCard.tsx +++ b/src/components/mobile/HealthDashboard/MetricCard.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; + import type { AlertSeverity } from '../../../services/healthMetrics'; // ─── Types ──────────────────────────────────────────────────────────────────── diff --git a/src/components/mobile/HealthDashboard/ThresholdEditor.tsx b/src/components/mobile/HealthDashboard/ThresholdEditor.tsx index c1819e2d..9b9bdaef 100644 --- a/src/components/mobile/HealthDashboard/ThresholdEditor.tsx +++ b/src/components/mobile/HealthDashboard/ThresholdEditor.tsx @@ -6,13 +6,8 @@ */ import React, { useState } from 'react'; -import { - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; +import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; + import type { AlertThresholds } from '../../../services/healthMetrics'; interface ThresholdEditorProps { @@ -37,10 +32,7 @@ const FIELDS: FieldConfig[] = [ { key: 'apiErrorRateCritical', label: 'API Error Rate Critical', unit: '%' }, ]; -export const ThresholdEditor: React.FC = ({ - thresholds, - onChange, -}) => { +export const ThresholdEditor: React.FC = ({ thresholds, onChange }) => { const [expanded, setExpanded] = useState(false); const handleChange = (key: keyof AlertThresholds, raw: string) => { @@ -53,7 +45,7 @@ export const ThresholdEditor: React.FC = ({ return ( setExpanded((v) => !v)} + onPress={() => setExpanded(v => !v)} style={styles.toggle} accessibilityRole="button" accessibilityLabel="Toggle threshold settings" @@ -73,7 +65,7 @@ export const ThresholdEditor: React.FC = ({ handleChange(key, v)} + onChangeText={v => handleChange(key, v)} keyboardType="numeric" accessibilityLabel={`${label} threshold`} returnKeyType="done" diff --git a/src/components/mobile/HealthDashboard/index.ts b/src/components/mobile/HealthDashboard/index.ts index 548f234f..c40736c9 100644 --- a/src/components/mobile/HealthDashboard/index.ts +++ b/src/components/mobile/HealthDashboard/index.ts @@ -4,4 +4,3 @@ export { HealthDashboard } from './HealthDashboard'; export { LatencyBar } from './LatencyBar'; export { MetricCard } from './MetricCard'; export { ThresholdEditor } from './ThresholdEditor'; - diff --git a/src/components/mobile/InfiniteVirtualList.tsx b/src/components/mobile/InfiniteVirtualList.tsx index 31f6527c..ab050fdf 100644 --- a/src/components/mobile/InfiniteVirtualList.tsx +++ b/src/components/mobile/InfiniteVirtualList.tsx @@ -1,15 +1,16 @@ import * as Device from 'expo-device'; import React, { useCallback, useMemo } from 'react'; import { - ActivityIndicator, - FlatList, - FlatListProps, - Platform, - StyleProp, - StyleSheet, - View, - ViewStyle, + ActivityIndicator, + FlatList, + FlatListProps, + Platform, + StyleProp, + StyleSheet, + View, + ViewStyle, } from 'react-native'; + import { useMemoryMonitor } from '../../hooks'; /** @@ -21,7 +22,7 @@ import { useMemoryMonitor } from '../../hooks'; */ export interface InfiniteVirtualListProps extends Omit, 'renderItem'> { /** The data items to display. */ - data: ReadonlyArray | null | undefined; + data: readonly T[] | null | undefined; /** Custom renderer for list items. */ renderItem: FlatListProps['renderItem']; /** Extract a unique key for a given item. */ diff --git a/src/components/mobile/MobileFormInput.tsx b/src/components/mobile/MobileFormInput.tsx index eef5c689..d07355c0 100644 --- a/src/components/mobile/MobileFormInput.tsx +++ b/src/components/mobile/MobileFormInput.tsx @@ -1,3 +1,4 @@ +import { Eye, EyeOff, AlertCircle } from 'lucide-react-native'; import React, { useCallback, useEffect, useState } from 'react'; import { View, @@ -11,7 +12,7 @@ import { TextInputSubmitEditingEventData, TextInputProps, } from 'react-native'; -import { Eye, EyeOff, AlertCircle } from 'lucide-react-native'; + import { useDynamicFontSize } from '../../hooks'; import { formCacheService, @@ -99,13 +100,16 @@ export const MobileFormInput: React.FC = ({ }; }, [cacheKey, isFocused, value]); - const handleBlur = useCallback((e: Parameters>[0]) => { - setIsFocused(false); - if (cacheKey && cacheOnBlur && value.trim()) { - void setCachedFieldValue(cacheKey, value); - } - onBlur?.(e); - }, [cacheKey, cacheOnBlur, value, onBlur]); + const handleBlur = useCallback( + (e: Parameters>[0]) => { + setIsFocused(false); + if (cacheKey && cacheOnBlur && value.trim()) { + void setCachedFieldValue(cacheKey, value); + } + onBlur?.(e); + }, + [cacheKey, cacheOnBlur, value, onBlur] + ); const handleApplySuggestion = useCallback(() => { if (suggestion) { @@ -123,7 +127,7 @@ export const MobileFormInput: React.FC = ({ return ( - + = ({ )} {hint && !error && ( - - {hint} - + {hint} )} - {leftIcon && {leftIcon}} + {leftIcon && {leftIcon}} = ({ {suggestion && !error && ( = ({ )} {error && ( - + - + {error} )} ); -}; \ No newline at end of file +}; diff --git a/src/components/mobile/MobileHeader.tsx b/src/components/mobile/MobileHeader.tsx index 453d900a..fb85e379 100644 --- a/src/components/mobile/MobileHeader.tsx +++ b/src/components/mobile/MobileHeader.tsx @@ -3,6 +3,7 @@ import { useNavigation } from '@react-navigation/native'; import { ArrowLeft, Bell, Menu } from 'lucide-react-native'; import React from 'react'; import { TouchableOpacity, View, StyleSheet } from 'react-native'; + import { useDynamicFontSize, usePendingRequests, useSafeArea } from '../../hooks'; import { AppText } from '../common/AppText'; @@ -22,13 +23,19 @@ interface MobileHeaderProps { stickyTop?: number; } -export const MobileHeader = ({ title, showBack = false, rightAction, sticky = false, stickyTop = 0 }: MobileHeaderProps) => { +export const MobileHeader = ({ + title, + showBack = false, + rightAction, + sticky = false, + stickyTop = 0, +}: MobileHeaderProps) => { const { top } = useSafeArea(); const navigation = useNavigation>(); const pendingCount = usePendingRequests(); const { scale } = useDynamicFontSize(); - const headerStyle = sticky + const headerStyle = sticky ? [styles.header, styles.stickyHeader, { top: stickyTop }] : styles.header; @@ -100,4 +107,4 @@ const styles = StyleSheet.create({ shadowOpacity: 0.1, shadowRadius: 4, }, -}); \ No newline at end of file +}); diff --git a/src/components/mobile/MobileProfile.tsx b/src/components/mobile/MobileProfile.tsx index 3712ed5a..366f5dc7 100644 --- a/src/components/mobile/MobileProfile.tsx +++ b/src/components/mobile/MobileProfile.tsx @@ -1,41 +1,43 @@ import { LinearGradient } from 'expo-linear-gradient'; import { - BookOpen, - Camera, - ChevronDown, - ChevronUp, - Clock, - Edit3, - Globe, - Mail, - MapPin, - Save, - Trophy, - User, - UserCheck, - UserPlus, - Users, - X, + BookOpen, + Camera, + ChevronDown, + ChevronUp, + Clock, + Edit3, + Globe, + Mail, + MapPin, + Save, + Trophy, + User, + UserCheck, + UserPlus, + Users, + X, } from 'lucide-react-native'; import React, { useCallback, useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; import { - ActivityIndicator, - SafeAreaView, - ScrollView, - StyleSheet, - TouchableOpacity, - View + ActivityIndicator, + SafeAreaView, + ScrollView, + StyleSheet, + TouchableOpacity, + View, } from 'react-native'; + +import { Achievement, AchievementBadges } from './AchievementBadges'; +import { AvatarCamera } from './AvatarCamera'; +import { MobileFormInput } from './MobileFormInput'; +import { StatisticsDisplay } from './StatisticsDisplay'; import { useFormCache } from '../../hooks/useFormCache'; import { PROFILE_FORM_CACHE_KEYS } from '../../services/formCache'; import { configureNext } from '../../utils/layoutAnimation'; 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'; // Enable LayoutAnimation on Android if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { @@ -277,7 +279,11 @@ export const MobileProfile: React.FC = ({ return ( - + @@ -458,7 +464,11 @@ export const MobileProfile: React.FC = ({ return ( - + {/* ── Profile Header ─────────────────────────────────────────────── */} { - // eslint-disable-next-line react-hooks/rules-of-hooks useHapticFeedback('light'); onAnswerSelect(question.id, optionIndex, question.multiple); }; const handleTrueFalse = (value: number) => { - // eslint-disable-next-line react-hooks/rules-of-hooks useHapticFeedback('light'); onAnswerSelect(question.id, value, false); }; diff --git a/src/components/mobile/MobileQuizManager/QuizResults.tsx b/src/components/mobile/MobileQuizManager/QuizResults.tsx index fa33be47..14dc5883 100644 --- a/src/components/mobile/MobileQuizManager/QuizResults.tsx +++ b/src/components/mobile/MobileQuizManager/QuizResults.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + import { Quiz } from '../../../types/course'; import PrimaryButton from '../../common/PrimaryButton'; diff --git a/src/components/mobile/MobileSearch.tsx b/src/components/mobile/MobileSearch.tsx index 2481a3c3..7b70133f 100644 --- a/src/components/mobile/MobileSearch.tsx +++ b/src/components/mobile/MobileSearch.tsx @@ -6,15 +6,15 @@ import { FilterField, FilterSheet, FilterValues } from './FilterSheet'; import { SearchHistory } from './SearchHistory'; import { SearchResultCard, SearchResultItem } from './SearchResultCard'; import { VoiceSearch } from './VoiceSearch'; -import { useSearchIndex } from '../../hooks/useSearchIndex'; import { useAnalytics, useDebounce, useDynamicFontSize, useMemoryMonitor } from '../../hooks'; import { usePrefetchImages } from '../../hooks/usePrefetchImages'; +import { useSearchIndex } from '../../hooks/useSearchIndex'; import { addToSearchHistory } from '../../utils/searchHistory'; import { AnalyticsEvent } from '../../utils/trackingEvents'; +import { buildTrie } from '../../utils/trie'; import { validateSearchQuery } from '../../utils/validation'; import { AppText as Text } from '../common/AppText'; import { DelegatedKeyboardAvoidingView } from '../common/DelegatedKeyboardAvoidingView'; -import { buildTrie } from '../../utils/trie'; const DEFAULT_FILTERS: FilterField[] = [ { @@ -41,9 +41,21 @@ const DEFAULT_FILTERS: FilterField[] = [ // Static fallback keywords used until the index-derived suggestions are ready. const FALLBACK_KEYWORDS = [ - 'React Native', 'Mobile Development', 'Expo', 'JavaScript', 'TypeScript', - 'Web Development', 'Design', 'CSS', 'HTML', 'Node.js', 'Python', - 'Machine Learning', 'beginner', 'intermediate', 'advanced', + 'React Native', + 'Mobile Development', + 'Expo', + 'JavaScript', + 'TypeScript', + 'Web Development', + 'Design', + 'CSS', + 'HTML', + 'Node.js', + 'Python', + 'Machine Learning', + 'beginner', + 'intermediate', + 'advanced', ]; export interface MobileSearchProps { @@ -85,14 +97,17 @@ export const MobileSearch = ({ typeof fontSizeScale.scale === 'function' ? fontSizeScale.scale : (value: number) => value; const { trackEvent } = useAnalytics(); - const { search: indexSearch, suggestions: indexSuggestions, isReady: indexReady } = - useSearchIndex(); + const { + search: indexSearch, + suggestions: indexSuggestions, + isReady: indexReady, + } = useSearchIndex(); useMemoryMonitor({ componentId: 'MobileSearch', itemCount: results.length }); const resultThumbnails = useMemo( () => results.map((r: SearchResultItem) => r.thumbnail ?? null), - [results], + [results] ); usePrefetchImages(resultThumbnails, { auto: true, limit: 10 }); @@ -101,9 +116,7 @@ export const MobileSearch = ({ // Build Trie from index-derived suggestions (real course titles / words) // falling back to static keywords until the index is ready. const suggestionTrie = useMemo(() => { - const words = indexReady && indexSuggestions.length > 0 - ? indexSuggestions - : FALLBACK_KEYWORDS; + const words = indexReady && indexSuggestions.length > 0 ? indexSuggestions : FALLBACK_KEYWORDS; return buildTrie(words); }, [indexReady, indexSuggestions]); @@ -132,12 +145,12 @@ export const MobileSearch = ({ setHasSearched(true); setSuggestionsVisible(false); }, - [filterValues, trackEvent, indexSearch], + [filterValues, trackEvent, indexSearch] ); const handleResultPress = useCallback( (item: SearchResultItem) => onResultPress?.(item), - [onResultPress], + [onResultPress] ); React.useEffect(() => { @@ -174,7 +187,7 @@ export const MobileSearch = ({ setQuery(text); performSearch(text); }, - [performSearch], + [performSearch] ); const handleHistorySelect = useCallback( @@ -182,7 +195,7 @@ export const MobileSearch = ({ setQuery(text); performSearch(text); }, - [performSearch], + [performSearch] ); const handleVoiceResult = useCallback( @@ -190,7 +203,7 @@ export const MobileSearch = ({ setQuery(text); performSearch(text); }, - [performSearch], + [performSearch] ); const handleApplyFilters = useCallback((values: FilterValues) => { diff --git a/src/components/mobile/MobileSyllabus.tsx b/src/components/mobile/MobileSyllabus.tsx index 43c1d25c..a24aca0b 100644 --- a/src/components/mobile/MobileSyllabus.tsx +++ b/src/components/mobile/MobileSyllabus.tsx @@ -1,11 +1,6 @@ import React, { useState } from 'react'; -import { - ScrollView, - StyleSheet, - Text, - TouchableOpacity, - View -} from 'react-native'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + import { CourseProgress, Lesson, Section } from '../../types/course'; /** diff --git a/src/components/mobile/MobileTabBar.tsx b/src/components/mobile/MobileTabBar.tsx index cecc1a86..a57ff91c 100644 --- a/src/components/mobile/MobileTabBar.tsx +++ b/src/components/mobile/MobileTabBar.tsx @@ -1,8 +1,9 @@ +import { BottomTabBarProps } from '@react-navigation/bottom-tabs'; +import { Home, Compass, PlusCircle, MessageCircle, User } from 'lucide-react-native'; import React from 'react'; import { View, Text, TouchableOpacity } from 'react-native'; -import { BottomTabBarProps } from '@react-navigation/bottom-tabs'; + import { useSafeArea } from '../../hooks'; -import { Home, Compass, PlusCircle, MessageCircle, User } from 'lucide-react-native'; /** * Custom bottom tab bar component for TeachLink mobile diff --git a/src/components/mobile/MobileVideoPlayer.tsx b/src/components/mobile/MobileVideoPlayer.tsx index 3389f9ca..edcd8bdd 100644 --- a/src/components/mobile/MobileVideoPlayer.tsx +++ b/src/components/mobile/MobileVideoPlayer.tsx @@ -2,27 +2,27 @@ import { Audio, AVPlaybackStatus, AVPlaybackStatusToSet, ResizeMode, Video } fro import * as Network from 'expo-network'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - ActivityIndicator, - Modal, - Pressable, - StyleProp, - StyleSheet, - Text, - View, - ViewStyle, + ActivityIndicator, + Modal, + Pressable, + StyleProp, + StyleSheet, + Text, + View, + ViewStyle, } from 'react-native'; import VideoControls from './VideoControls'; import { usePictureInPicture, useVideoGestures } from '../../hooks'; import { - AUTO_QUALITY_ID, - deriveNetworkType, - getQualityOptions, - normalizeSources, - selectSourceById, - type NetworkType, - type NormalizedVideoSource, - type VideoSource, + AUTO_QUALITY_ID, + deriveNetworkType, + getQualityOptions, + normalizeSources, + selectSourceById, + type NetworkType, + type NormalizedVideoSource, + type VideoSource, } from '../../services/videoQuality'; import { ErrorBoundary } from '../common/ErrorBoundary'; diff --git a/src/components/mobile/NativeToggle.tsx b/src/components/mobile/NativeToggle.tsx index 56dbd184..a7c80d84 100644 --- a/src/components/mobile/NativeToggle.tsx +++ b/src/components/mobile/NativeToggle.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { View, Text, Switch, TouchableOpacity } from 'react-native'; + import { useHapticFeedback } from '../../hooks'; interface NativeToggleProps { @@ -25,7 +26,7 @@ interface NativeToggleProps { * pressable row; otherwise just the bare Switch is returned so it can be * embedded inside a parent row. */ -export function NativeToggle({ +export const NativeToggle = ({ value, onValueChange, label, @@ -33,9 +34,8 @@ export function NativeToggle({ disabled = false, activeTrackColor = '#19c3e6', activeThumbColor = '#0099b3', -}: NativeToggleProps) { +}: NativeToggleProps) => { const handleChange = (newValue: boolean) => { - // eslint-disable-next-line react-hooks/rules-of-hooks useHapticFeedback('light'); onValueChange(newValue); }; @@ -69,6 +69,6 @@ export function NativeToggle({ {switchControl} ); -} +}; export default NativeToggle; diff --git a/src/components/mobile/NotificationPrompt.tsx b/src/components/mobile/NotificationPrompt.tsx index 24725497..7208335e 100644 --- a/src/components/mobile/NotificationPrompt.tsx +++ b/src/components/mobile/NotificationPrompt.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Modal, SafeAreaView, Text, TouchableOpacity, View } from 'react-native'; + import { useNotificationPermission } from '../../hooks'; import { useNotificationStore } from '../../store/notificationStore'; import { ErrorBoundary } from '../common/ErrorBoundary'; @@ -24,7 +25,7 @@ interface NotificationTypeItemProps { description: string; } -function NotificationTypeItem({ icon, title, description }: NotificationTypeItemProps) { +const NotificationTypeItem = ({ icon, title, description }: NotificationTypeItemProps) => { return ( @@ -36,14 +37,14 @@ function NotificationTypeItem({ icon, title, description }: NotificationTypeItem ); -} +}; -export function NotificationPrompt({ +export const NotificationPrompt = ({ visible, onClose, onPermissionGranted, onPermissionDenied, -}: NotificationPromptProps) { +}: NotificationPromptProps) => { const { requestPermission, isLoading, isDevice, openSettings, permissionStatus } = useNotificationPermission(); const { setHasPromptedForPermission, setPermissionDeniedAt } = useNotificationStore(); @@ -178,6 +179,6 @@ export function NotificationPrompt({ ); -} +}; export default NotificationPrompt; diff --git a/src/components/mobile/NotificationSettings.tsx b/src/components/mobile/NotificationSettings.tsx index a84343a0..9393253f 100644 --- a/src/components/mobile/NotificationSettings.tsx +++ b/src/components/mobile/NotificationSettings.tsx @@ -1,13 +1,7 @@ -import React, { memo, useState } from 'react'; import { ChevronDown, ChevronUp } from 'lucide-react-native'; -import React, { useState } from 'react'; -import { - ScrollView, - Switch, - Text, - TouchableOpacity, - View -} from 'react-native'; +import React, { memo, useState } from 'react'; +import { ScrollView, Switch, Text, TouchableOpacity, View } from 'react-native'; + import { useNotificationPermission } from '../../hooks'; import { useNotificationStore } from '../../store/notificationStore'; import { NotificationPreferences } from '../../types/notifications'; @@ -51,7 +45,7 @@ const SettingRow = memo(function SettingRow({ ); }); -export function NotificationSettings() { +export const NotificationSettings = () => { const { permissionStatus, requestPermission, openSettings, isLoading } = useNotificationPermission(); const { preferences, setPreference, pushToken } = useNotificationStore(); @@ -224,6 +218,6 @@ export function NotificationSettings() { ); -} +}; export default NotificationSettings; diff --git a/src/components/mobile/ProfiledScreen.tsx b/src/components/mobile/ProfiledScreen.tsx index 39f96894..030481fc 100644 --- a/src/components/mobile/ProfiledScreen.tsx +++ b/src/components/mobile/ProfiledScreen.tsx @@ -1,4 +1,5 @@ import React, { Profiler, ReactNode } from 'react'; + import { useReactProfiler, ProfilerOptions } from '../../hooks/useReactProfiler'; interface ProfiledScreenProps { diff --git a/src/components/mobile/PullToRefresh.tsx b/src/components/mobile/PullToRefresh.tsx index 8acb0f3c..6b9eb0f1 100644 --- a/src/components/mobile/PullToRefresh.tsx +++ b/src/components/mobile/PullToRefresh.tsx @@ -9,6 +9,7 @@ import { View, ViewStyle, } from 'react-native'; + import { useAdaptiveFrameRate } from '../../hooks/useAdaptiveFrameRate'; type AnyScrollComponent = React.ComponentType; @@ -57,7 +58,7 @@ function clamp(v: number, min: number, max: number): number { * - Avoids re-renders during drag by updating Animated.Value directly. * - Provides a screen-reader friendly button fallback (optional). */ -export function PullToRefresh(props: PullToRefreshProps) { +export const PullToRefresh = (props: PullToRefreshProps) => { const { ScrollComponent = Animated.ScrollView, scrollProps, @@ -97,7 +98,7 @@ export function PullToRefresh(props: PullToRefreshProps) { return () => { mounted = false; // RN types vary by version; guard-remove. - // eslint-disable-next-line @typescript-eslint/no-explicit-any + (sub as any)?.remove?.(); }; }, []); @@ -212,7 +213,7 @@ export function PullToRefresh(props: PullToRefreshProps) { return ( {showA11yFallbackButton && screenReaderEnabled ? ( - + Refresh @@ -255,4 +256,4 @@ export function PullToRefresh(props: PullToRefreshProps) { ); -} +}; diff --git a/src/components/mobile/PurchaseButton.tsx b/src/components/mobile/PurchaseButton.tsx index 5435ac4b..5c6220d2 100644 --- a/src/components/mobile/PurchaseButton.tsx +++ b/src/components/mobile/PurchaseButton.tsx @@ -1,7 +1,7 @@ -import React from 'react'; -import { TouchableOpacity, Text, View, ActivityIndicator, StyleSheet } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { Check, Zap, Lock } from 'lucide-react-native'; +import React from 'react'; +import { TouchableOpacity, Text, View, ActivityIndicator, StyleSheet } from 'react-native'; // ─── Types ──────────────────────────────────────────────────────────────────── diff --git a/src/components/mobile/QRScanner.tsx b/src/components/mobile/QRScanner.tsx index 4171981d..89f4909e 100644 --- a/src/components/mobile/QRScanner.tsx +++ b/src/components/mobile/QRScanner.tsx @@ -1,6 +1,6 @@ +import { BarCodeScanner, BarCodeScannerResult } from 'expo-barcode-scanner'; import React, { useEffect, useState } from 'react'; import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { BarCodeScanner, BarCodeScannerResult } from 'expo-barcode-scanner'; interface QRScannerProps { onLinkScanned: (value: string) => void; diff --git a/src/components/mobile/RequestTimeoutOverlay.tsx b/src/components/mobile/RequestTimeoutOverlay.tsx index a6fadd43..02cd5e90 100644 --- a/src/components/mobile/RequestTimeoutOverlay.tsx +++ b/src/components/mobile/RequestTimeoutOverlay.tsx @@ -1,11 +1,6 @@ import React from 'react'; -import { - View, - Text, - TouchableOpacity, - StyleSheet, - Animated, -} from 'react-native'; +import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native'; + import { useRequestTimeout, REQUEST_TIMEOUT_MS } from '../../hooks/useRequestTimeout'; export interface RequestTimeoutOverlayProps { @@ -23,14 +18,13 @@ export interface RequestTimeoutOverlayProps { * Shows a countdown progress bar while a request is in-flight. * When the timeout is reached, displays a Retry button. */ -export function RequestTimeoutOverlay({ +export const RequestTimeoutOverlay = ({ loading, onRetry, timeoutMs = REQUEST_TIMEOUT_MS, message = 'Waiting for response…', -}: RequestTimeoutOverlayProps) { - const { progress, remaining, isTimedOut, start, reset } = - useRequestTimeout(timeoutMs); +}: RequestTimeoutOverlayProps) => { + const { progress, remaining, isTimedOut, start, reset } = useRequestTimeout(timeoutMs); // Start countdown when loading begins, reset when it ends React.useEffect(() => { @@ -53,9 +47,7 @@ export function RequestTimeoutOverlay({ return ( {/* Message + countdown */} - - {isTimedOut ? 'Request timed out' : message} - + {isTimedOut ? 'Request timed out' : message} {!isTimedOut && ( {secondsLeft}s @@ -80,7 +72,7 @@ export function RequestTimeoutOverlay({ )} ); -} +}; const styles = StyleSheet.create({ container: { diff --git a/src/components/mobile/SearchHistory.tsx b/src/components/mobile/SearchHistory.tsx index ff08bbd1..8963d4ea 100644 --- a/src/components/mobile/SearchHistory.tsx +++ b/src/components/mobile/SearchHistory.tsx @@ -1,3 +1,4 @@ +import { Clock, Trash2 } from 'lucide-react-native'; import React, { useCallback, useEffect, useState } from 'react'; import { View, @@ -7,21 +8,21 @@ import { ActivityIndicator, StyleSheet, } from 'react-native'; -import { Clock, Trash2 } from 'lucide-react-native'; + +import { useMemoryMonitor } from '../../hooks'; import { getSearchHistory, clearSearchHistory, removeFromSearchHistory, SearchHistoryItem, } from '../../utils/searchHistory'; -import { useMemoryMonitor } from '../../hooks'; export interface SearchHistoryProps { onSelectQuery: (query: string) => void; maxItems?: number; } -export function SearchHistory({ onSelectQuery, maxItems = 10 }: SearchHistoryProps) { +export const SearchHistory = ({ onSelectQuery, maxItems = 10 }: SearchHistoryProps) => { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); @@ -114,7 +115,7 @@ export function SearchHistory({ onSelectQuery, maxItems = 10 }: SearchHistoryPro /> ); -} +}; const styles = StyleSheet.create({ container: { diff --git a/src/components/mobile/SearchResultCard.tsx b/src/components/mobile/SearchResultCard.tsx index 1348ce27..61ad2ee3 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 } from 'react-native'; -import { BookOpen, Clock } from 'lucide-react-native'; export interface SearchResultItem { id: string; @@ -19,56 +19,59 @@ export interface SearchResultCardProps { onPress: () => void; } -export const SearchResultCard = React.memo(function SearchResultCard({ - item, - onPress, -}: SearchResultCardProps) { - const metaParts = [item.category, item.level].filter(Boolean); - const metaText = metaParts.join(' · '); - const screenReaderDescription = [item.title, item.description || item.subtitle, metaText] - .filter(Boolean) - .join('. '); +export const SearchResultCard = React.memo( + function SearchResultCard({ item, onPress }: SearchResultCardProps) { + const metaParts = [item.category, item.level].filter(Boolean); + const metaText = metaParts.join(' · '); + const screenReaderDescription = [item.title, item.description || item.subtitle, metaText] + .filter(Boolean) + .join('. '); - return ( - - - - - - - {item.title} - - {(item.description || item.subtitle) && ( - - {item.description || item.subtitle} + return ( + + + + + + + {item.title} - )} - - {metaText ? {metaText} : null} - {item.duration != null && item.duration > 0 && ( - - - {item.duration} min - + {(item.description || item.subtitle) && ( + + {item.description || item.subtitle} + )} + + {metaText ? ( + {metaText} + ) : null} + {item.duration != null && item.duration > 0 && ( + + + {item.duration} min + + )} + - - - ); -}, (prev, next) => { - return prev.item.id === next.item.id - && prev.item.title === next.item.title - && prev.item.description === next.item.description - && prev.item.subtitle === next.item.subtitle - && prev.item.duration === next.item.duration - && prev.item.category === next.item.category - && prev.item.level === next.item.level; -}); - + + ); + }, + (prev, next) => { + return ( + prev.item.id === next.item.id && + prev.item.title === next.item.title && + prev.item.description === next.item.description && + prev.item.subtitle === next.item.subtitle && + prev.item.duration === next.item.duration && + prev.item.category === next.item.category && + prev.item.level === next.item.level + ); + } +); diff --git a/src/components/mobile/SettingsPicker.tsx b/src/components/mobile/SettingsPicker.tsx index f2414c45..cde785ee 100644 --- a/src/components/mobile/SettingsPicker.tsx +++ b/src/components/mobile/SettingsPicker.tsx @@ -1,6 +1,7 @@ import { Check, ChevronRight } from 'lucide-react-native'; import React, { useState } from 'react'; import { Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + import { useHapticFeedback } from '../../hooks'; import { ErrorBoundary } from '../common/ErrorBoundary'; @@ -25,7 +26,7 @@ interface SettingsPickerProps { * showing all available options. The selected option gets a cyan checkmark. * Matches the iOS-style grouped settings aesthetic used throughout the app. */ -export function SettingsPicker({ +export const SettingsPicker = ({ value, options, onValueChange, @@ -33,13 +34,12 @@ export function SettingsPicker({ description, icon, disabled = false, -}: SettingsPickerProps) { +}: SettingsPickerProps) => { const [isOpen, setIsOpen] = useState(false); const selectedLabel = options.find(o => o.value === value)?.label ?? value; const handleSelect = (optionValue: T) => { - // eslint-disable-next-line react-hooks/rules-of-hooks useHapticFeedback('light'); onValueChange(optionValue); setIsOpen(false); @@ -152,7 +152,7 @@ export function SettingsPicker({ ); -} +}; const styles = StyleSheet.create({ modalContainer: { diff --git a/src/components/mobile/SettingsSection.tsx b/src/components/mobile/SettingsSection.tsx index 0c39bb90..6b6d456a 100644 --- a/src/components/mobile/SettingsSection.tsx +++ b/src/components/mobile/SettingsSection.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { View } from 'react-native'; + import { AppText } from '../common/AppText'; interface SettingsSectionProps { @@ -22,7 +23,7 @@ interface SettingsSectionProps { * * ``` */ -export function SettingsSection({ title, footer, children }: SettingsSectionProps) { +export const SettingsSection = ({ title, footer, children }: SettingsSectionProps) => { const childArray = React.Children.toArray(children).filter(Boolean); return ( @@ -57,6 +58,6 @@ export function SettingsSection({ title, footer, children }: SettingsSectionProp ) : null} ); -} +}; export default SettingsSection; diff --git a/src/components/mobile/SubscriptionManager.tsx b/src/components/mobile/SubscriptionManager.tsx index 5a96bdee..99b500a8 100644 --- a/src/components/mobile/SubscriptionManager.tsx +++ b/src/components/mobile/SubscriptionManager.tsx @@ -1,3 +1,5 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import { Crown, Zap, Check, RefreshCw, ChevronRight, Star, Shield } from 'lucide-react-native'; import React, { useEffect, useState } from 'react'; import { View, @@ -9,8 +11,7 @@ import { Alert, ActivityIndicator, } from 'react-native'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Crown, Zap, Check, RefreshCw, ChevronRight, Star, Shield } from 'lucide-react-native'; + import { PurchaseButton } from './PurchaseButton'; import { SubscriptionSkeleton } from './SubscriptionSkeleton'; import { useInAppPurchase } from '../../hooks'; @@ -326,7 +327,11 @@ export const SubscriptionManager: React.FC = ({ )} - + {/* Current plan */} {renderCurrentPlan()} diff --git a/src/components/mobile/SwipeableRow.tsx b/src/components/mobile/SwipeableRow.tsx index 5a67964f..f35426c3 100644 --- a/src/components/mobile/SwipeableRow.tsx +++ b/src/components/mobile/SwipeableRow.tsx @@ -132,13 +132,16 @@ export const SwipeableRow: React.FC = ({ } }); - const handleLayout = useCallback((event: LayoutChangeEvent) => { - if (layoutHeight === null) { - const { height } = event.nativeEvent.layout; - setLayoutHeight(height); - itemHeight.value = height; - } - }, [layoutHeight, itemHeight]); + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + if (layoutHeight === null) { + const { height } = event.nativeEvent.layout; + setLayoutHeight(height); + itemHeight.value = height; + } + }, + [layoutHeight, itemHeight] + ); const executeDelete = useCallback(() => { isDeletedShared.value = true; diff --git a/src/components/mobile/TeamDashboard/AlertBanner.tsx b/src/components/mobile/TeamDashboard/AlertBanner.tsx index d0a09f76..cc8134e4 100644 --- a/src/components/mobile/TeamDashboard/AlertBanner.tsx +++ b/src/components/mobile/TeamDashboard/AlertBanner.tsx @@ -4,9 +4,11 @@ import React from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; -import type { DashboardAlert } from '../../../services/metricsService'; + import { AppText as Text } from '../../common/AppText'; +import type { DashboardAlert } from '../../../services/metricsService'; + interface AlertBannerProps { alert: DashboardAlert; onDismiss?: (id: string) => void; @@ -26,16 +28,11 @@ const SEVERITY_COLOURS_DARK = { }; export const AlertBanner: React.FC = ({ alert, onDismiss, isDark = false }) => { - const colours = isDark - ? SEVERITY_COLOURS_DARK[alert.severity] - : SEVERITY_COLOURS[alert.severity]; + const colours = isDark ? SEVERITY_COLOURS_DARK[alert.severity] : SEVERITY_COLOURS[alert.severity]; return ( diff --git a/src/components/mobile/TeamDashboard/DashboardSkeleton.tsx b/src/components/mobile/TeamDashboard/DashboardSkeleton.tsx index 351ed796..75b5aeae 100644 --- a/src/components/mobile/TeamDashboard/DashboardSkeleton.tsx +++ b/src/components/mobile/TeamDashboard/DashboardSkeleton.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { StyleSheet, View } from 'react-native'; + import { Skeleton } from '../../ui/Skeleton'; interface DashboardSkeletonProps { @@ -23,7 +24,7 @@ export const DashboardSkeleton: React.FC = ({ isDark = f {/* View tabs */} - {[1, 2, 3, 4].map((i) => ( + {[1, 2, 3, 4].map(i => ( ))} @@ -39,7 +40,7 @@ export const DashboardSkeleton: React.FC = ({ isDark = f {/* Metric grid */} - {[1, 2, 3, 4].map((i) => ( + {[1, 2, 3, 4].map(i => ( ))} diff --git a/src/components/mobile/TeamDashboard/HealthScoreRing.tsx b/src/components/mobile/TeamDashboard/HealthScoreRing.tsx index 471674f6..16a1ab40 100644 --- a/src/components/mobile/TeamDashboard/HealthScoreRing.tsx +++ b/src/components/mobile/TeamDashboard/HealthScoreRing.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { StyleSheet, View } from 'react-native'; import Svg, { Circle, Text as SvgText } from 'react-native-svg'; + import { AppText as Text } from '../../common/AppText'; interface HealthScoreRingProps { @@ -47,7 +48,10 @@ export const HealthScoreRing: React.FC = ({ const strokeDashoffset = circumference - (progress / 100) * circumference; return ( - + {/* Track */} = ({ > {score} - + / 100 diff --git a/src/components/mobile/TeamDashboard/MetricCard.tsx b/src/components/mobile/TeamDashboard/MetricCard.tsx index ee5d2d2f..652bb971 100644 --- a/src/components/mobile/TeamDashboard/MetricCard.tsx +++ b/src/components/mobile/TeamDashboard/MetricCard.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { StyleSheet, View } from 'react-native'; + import { AppText as Text } from '../../common/AppText'; export type MetricCardStatus = 'healthy' | 'warning' | 'critical' | 'neutral'; diff --git a/src/components/mobile/TeamDashboard/TeamDashboard.tsx b/src/components/mobile/TeamDashboard/TeamDashboard.tsx index 4ad3df2b..cda62f56 100644 --- a/src/components/mobile/TeamDashboard/TeamDashboard.tsx +++ b/src/components/mobile/TeamDashboard/TeamDashboard.tsx @@ -9,22 +9,18 @@ */ import React, { useCallback, useState } from 'react'; -import { - RefreshControl, - ScrollView, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native'; +import { RefreshControl, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useDashboardMetrics } from '../../../hooks/useDashboardMetrics'; -import type { DashboardAlert } from '../../../services/metricsService'; -import type { DashboardView } from '../../../store/metricsStore'; -import { AppText as Text } from '../../common/AppText'; + import { AlertBanner } from './AlertBanner'; import { DashboardSkeleton } from './DashboardSkeleton'; import { HealthScoreRing } from './HealthScoreRing'; import { MetricCard } from './MetricCard'; +import { useDashboardMetrics } from '../../../hooks/useDashboardMetrics'; +import { AppText as Text } from '../../common/AppText'; + +import type { DashboardAlert } from '../../../services/metricsService'; +import type { DashboardView } from '../../../store/metricsStore'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -81,7 +77,7 @@ export const TeamDashboard: React.FC = ({ isDark = false }) const [dismissedAlerts, setDismissedAlerts] = useState>(new Set()); const handleDismissAlert = useCallback((id: string) => { - setDismissedAlerts((prev) => new Set([...prev, id])); + setDismissedAlerts(prev => new Set([...prev, id])); }, []); const visibleAlerts = alerts.filter((a: DashboardAlert) => !dismissedAlerts.has(a.id)); @@ -155,14 +151,26 @@ export const TeamDashboard: React.FC = ({ isDark = false }) = 99 ? 'healthy' : appHealth.uptimePercent >= 95 ? 'warning' : 'critical'} + status={ + appHealth.uptimePercent >= 99 + ? 'healthy' + : appHealth.uptimePercent >= 95 + ? 'warning' + : 'critical' + } icon="🟢" isDark={isDark} /> @@ -178,10 +186,10 @@ export const TeamDashboard: React.FC = ({ isDark = false }) performance.avgApiResponseMs === 0 ? 'neutral' : performance.avgApiResponseMs < 500 - ? 'healthy' - : performance.avgApiResponseMs < 2000 - ? 'warning' - : 'critical' + ? 'healthy' + : performance.avgApiResponseMs < 2000 + ? 'warning' + : 'critical' } icon="🌐" isDark={isDark} @@ -193,26 +201,30 @@ export const TeamDashboard: React.FC = ({ isDark = false }) errorRate.errorsPerMinute === 0 ? 'healthy' : errorRate.errorsPerMinute < 5 - ? 'warning' - : 'critical' + ? 'warning' + : 'critical' + } + subLabel={ + errorRate.trend === 'improving' + ? '↓ improving' + : errorRate.trend === 'degrading' + ? '↑ degrading' + : '→ stable' } - subLabel={errorRate.trend === 'improving' ? '↓ improving' : errorRate.trend === 'degrading' ? '↑ degrading' : '→ stable'} icon="📈" isDark={isDark} /> 0 ? formatBytes(performance.usedHeapBytes) : 'N/A' - } + value={performance.usedHeapBytes > 0 ? formatBytes(performance.usedHeapBytes) : 'N/A'} status={ performance.heapUtilPercent === 0 ? 'neutral' : performance.heapUtilPercent < 60 - ? 'healthy' - : performance.heapUtilPercent < 80 - ? 'warning' - : 'critical' + ? 'healthy' + : performance.heapUtilPercent < 80 + ? 'warning' + : 'critical' } icon="💾" isDark={isDark} @@ -245,10 +257,10 @@ export const TeamDashboard: React.FC = ({ isDark = false }) performance.avgApiResponseMs === 0 ? 'neutral' : performance.avgApiResponseMs < 500 - ? 'healthy' - : performance.avgApiResponseMs < 2000 - ? 'warning' - : 'critical' + ? 'healthy' + : performance.avgApiResponseMs < 2000 + ? 'warning' + : 'critical' } icon="🌐" isDark={isDark} @@ -260,10 +272,10 @@ export const TeamDashboard: React.FC = ({ isDark = false }) performance.heapUtilPercent === 0 ? 'neutral' : performance.heapUtilPercent < 60 - ? 'healthy' - : performance.heapUtilPercent < 80 - ? 'warning' - : 'critical' + ? 'healthy' + : performance.heapUtilPercent < 80 + ? 'warning' + : 'critical' } icon="💾" isDark={isDark} @@ -282,10 +294,10 @@ export const TeamDashboard: React.FC = ({ isDark = false }) performance.heapUtilPercent === 0 ? 'neutral' : performance.heapUtilPercent < 60 - ? 'healthy' - : performance.heapUtilPercent < 80 - ? 'warning' - : 'critical' + ? 'healthy' + : performance.heapUtilPercent < 80 + ? 'warning' + : 'critical' } icon="📊" isDark={isDark} @@ -299,13 +311,7 @@ export const TeamDashboard: React.FC = ({ isDark = false }) isDark={isDark} /> ) : ( - + )} ); @@ -320,8 +326,8 @@ export const TeamDashboard: React.FC = ({ isDark = false }) errorRate.errorsPerMinute === 0 ? 'healthy' : errorRate.errorsPerMinute < 5 - ? 'warning' - : 'critical' + ? 'warning' + : 'critical' } icon="📈" isDark={isDark} @@ -333,8 +339,8 @@ export const TeamDashboard: React.FC = ({ isDark = false }) errorRate.totalErrors === 0 ? 'healthy' : errorRate.totalErrors < 10 - ? 'warning' - : 'critical' + ? 'warning' + : 'critical' } icon="🐛" isDark={isDark} @@ -352,15 +358,15 @@ export const TeamDashboard: React.FC = ({ isDark = false }) errorRate.trend === 'improving' ? '↓ Improving' : errorRate.trend === 'degrading' - ? '↑ Degrading' - : '→ Stable' + ? '↑ Degrading' + : '→ Stable' } status={ errorRate.trend === 'improving' ? 'healthy' : errorRate.trend === 'degrading' - ? 'warning' - : 'neutral' + ? 'warning' + : 'neutral' } icon="📉" isDark={isDark} @@ -370,7 +376,7 @@ export const TeamDashboard: React.FC = ({ isDark = false }) {errorRate.byCategory.length > 0 ? ( By Category - {errorRate.byCategory.map((item) => ( + {errorRate.byCategory.map(item => ( = ({ isDark = false }) {item.category} = ({ isDark = false }) icon="🎭" isDark={isDark} /> - + ); @@ -489,7 +486,7 @@ export const TeamDashboard: React.FC = ({ isDark = false }) style={[ styles.refreshToggle, { - backgroundColor: autoRefreshEnabled ? '#19c3e6' : (isDark ? '#334155' : '#e2e8f0'), + backgroundColor: autoRefreshEnabled ? '#19c3e6' : isDark ? '#334155' : '#e2e8f0', }, ]} onPress={() => setAutoRefresh(!autoRefreshEnabled)} @@ -532,7 +529,7 @@ export const TeamDashboard: React.FC = ({ isDark = false }) style={styles.tabsScroll} contentContainerStyle={styles.tabsContainer} > - {VIEW_TABS.map((tab) => { + {VIEW_TABS.map(tab => { const isActive = activeView === tab.key; return ( = ({ isDark = false }) style={[ styles.tab, { - backgroundColor: isActive ? tabActiveColor : (isDark ? '#1e293b' : '#f1f5f9'), + backgroundColor: isActive ? tabActiveColor : isDark ? '#1e293b' : '#f1f5f9', borderColor: isActive ? tabActiveColor : borderColor, }, ]} @@ -550,12 +547,7 @@ export const TeamDashboard: React.FC = ({ isDark = false }) accessibilityLabel={`${tab.label} view`} > {tab.icon} - + {tab.label} diff --git a/src/components/mobile/TeamDashboard/index.ts b/src/components/mobile/TeamDashboard/index.ts index 0696ab52..c3475b83 100644 --- a/src/components/mobile/TeamDashboard/index.ts +++ b/src/components/mobile/TeamDashboard/index.ts @@ -3,4 +3,3 @@ export { DashboardSkeleton } from './DashboardSkeleton'; export { HealthScoreRing } from './HealthScoreRing'; export { MetricCard } from './MetricCard'; export { TeamDashboard } from './TeamDashboard'; - diff --git a/src/components/mobile/VideoControls.tsx b/src/components/mobile/VideoControls.tsx index 5dc2953c..961e80c5 100644 --- a/src/components/mobile/VideoControls.tsx +++ b/src/components/mobile/VideoControls.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Animated, Pressable, Text, View } from 'react-native'; + import type { QualityOption } from '../../services/videoQuality'; type VideoControlsProps = { @@ -99,39 +100,51 @@ const VideoControls = ({ setSeekBarWidth(event.nativeEvent.layout.width); }, []); - const positionFromEvent = useCallback((event: any) => { - if (seekBarWidth <= 0 || durationMillis <= 0) { - return 0; - } - const x = event.nativeEvent.locationX; - return clamp((x / seekBarWidth) * durationMillis, 0, durationMillis); - }, [seekBarWidth, durationMillis]); + const positionFromEvent = useCallback( + (event: any) => { + if (seekBarWidth <= 0 || durationMillis <= 0) { + return 0; + } + const x = event.nativeEvent.locationX; + return clamp((x / seekBarWidth) * durationMillis, 0, durationMillis); + }, + [seekBarWidth, durationMillis] + ); - const handleSeekGrant = useCallback((event: any) => { - if (!durationMillis) { - return; - } - onSeekStart?.(); - const position = positionFromEvent(event); - onSeekPreview?.(position); - }, [durationMillis, onSeekStart, onSeekPreview, positionFromEvent]); + const handleSeekGrant = useCallback( + (event: any) => { + if (!durationMillis) { + return; + } + onSeekStart?.(); + const position = positionFromEvent(event); + onSeekPreview?.(position); + }, + [durationMillis, onSeekStart, onSeekPreview, positionFromEvent] + ); - const handleSeekMove = useCallback((event: any) => { - if (!durationMillis) { - return; - } - const position = positionFromEvent(event); - onSeekPreview?.(position); - }, [durationMillis, onSeekPreview, positionFromEvent]); + const handleSeekMove = useCallback( + (event: any) => { + if (!durationMillis) { + return; + } + const position = positionFromEvent(event); + onSeekPreview?.(position); + }, + [durationMillis, onSeekPreview, positionFromEvent] + ); - const handleSeekRelease = useCallback((event: any) => { - if (!durationMillis) { - return; - } - const position = positionFromEvent(event); - onSeek(position); - onSeekEnd?.(); - }, [durationMillis, positionFromEvent, onSeek, onSeekEnd]); + const handleSeekRelease = useCallback( + (event: any) => { + if (!durationMillis) { + return; + } + const position = positionFromEvent(event); + onSeek(position); + onSeekEnd?.(); + }, + [durationMillis, positionFromEvent, onSeek, onSeekEnd] + ); const handleSeekTerminate = useCallback(() => { onSeekEnd?.(); @@ -145,15 +158,21 @@ const VideoControls = ({ setIsQualityMenuOpen(prev => !prev); }, []); - const handleSelectRate = useCallback((rate: number) => { - onChangeRate(rate); - setIsSpeedMenuOpen(false); - }, [onChangeRate]); + const handleSelectRate = useCallback( + (rate: number) => { + onChangeRate(rate); + setIsSpeedMenuOpen(false); + }, + [onChangeRate] + ); - const handleSelectQualityOption = useCallback((qualityId: string) => { - onSelectQuality(qualityId); - setIsQualityMenuOpen(false); - }, [onSelectQuality]); + const handleSelectQualityOption = useCallback( + (qualityId: string) => { + onSelectQuality(qualityId); + setIsQualityMenuOpen(false); + }, + [onSelectQuality] + ); return ( - {isPiPActive ? 'PiP On' : 'PiP'} + + {isPiPActive ? 'PiP On' : 'PiP'} + ) : null} - {isFullscreen ? 'Exit' : 'Full'} + {isFullscreen ? 'Exit' : 'Full'} @@ -187,28 +208,28 @@ const VideoControls = ({ - {isPlaying ? 'Pause' : 'Play'} + {isPlaying ? 'Pause' : 'Play'} {previewPositionMillis != null ? ( - - + + {formatTime(displayPosition)} / {formatTime(durationMillis)} ) : null} - - {formatTime(displayPosition)} - {formatTime(durationMillis)} + + {formatTime(displayPosition)} + {formatTime(durationMillis)} true} onResponderGrant={handleSeekGrant} @@ -216,8 +237,14 @@ const VideoControls = ({ onResponderRelease={handleSeekRelease} onResponderTerminate={handleSeekTerminate} > - - + + - {formatRate(playbackRate)} + {formatRate(playbackRate)} - {qualityLabel} + {qualityLabel} {isPiPSupported ? ( - {isPiPActive ? 'PiP On' : 'PiP'} + + {isPiPActive ? 'PiP On' : 'PiP'} + ) : null} - {isFullscreen ? 'Exit' : 'Full'} + + {isFullscreen ? 'Exit' : 'Full'} + {(isSpeedMenuOpen || isQualityMenuOpen) && ( {isSpeedMenuOpen ? ( - + {rateOptions.map(rate => ( {formatRate(rate)} @@ -288,7 +319,7 @@ const VideoControls = ({ ) : null} {isQualityMenuOpen ? ( - + {qualityOptions.map(option => ( {option.label} @@ -312,7 +343,7 @@ const VideoControls = ({ {(isLoading || isBuffering || isSwitchingQuality) && ( - + {isSwitchingQuality ? 'Switching quality' : isBuffering ? 'Buffering' : 'Loading'} @@ -349,4 +380,3 @@ function clamp(value: number, min: number, max: number) { } export default VideoControls; - diff --git a/src/components/mobile/VirtualList.tsx b/src/components/mobile/VirtualList.tsx index f0b90cac..7d30d10e 100644 --- a/src/components/mobile/VirtualList.tsx +++ b/src/components/mobile/VirtualList.tsx @@ -1,5 +1,6 @@ import React, { useCallback } from 'react'; import { FlatList, FlatListProps, ViewStyle, StyleProp } from 'react-native'; + import { useMemoryMonitor } from '../../hooks'; /** @@ -11,7 +12,7 @@ import { useMemoryMonitor } from '../../hooks'; */ export interface VirtualListProps extends Omit, 'renderItem'> { - data: ReadonlyArray | null | undefined; + data: readonly T[] | null | undefined; renderItem: FlatListProps['renderItem']; keyExtractor: (item: T, index: number) => string; /** Optional: If items have fixed height, this drastically improves layout performance */ diff --git a/src/components/mobile/VoiceSearch.tsx b/src/components/mobile/VoiceSearch.tsx index 8d51206f..6efc88b8 100644 --- a/src/components/mobile/VoiceSearch.tsx +++ b/src/components/mobile/VoiceSearch.tsx @@ -74,7 +74,7 @@ export const VoiceSearch = ({ @@ -90,14 +90,14 @@ export const VoiceSearch = ({ return ( {error ? ( - + {error} ) : null} Voice @@ -121,9 +119,9 @@ export const VoiceSearch = ({ )} {isListening && ( - + - + {transcript || 'Listening...'} @@ -131,4 +129,3 @@ export const VoiceSearch = ({ ); }; - diff --git a/src/components/mobile/index.ts b/src/components/mobile/index.ts index 301c0780..2747bfa6 100644 --- a/src/components/mobile/index.ts +++ b/src/components/mobile/index.ts @@ -1,39 +1,13 @@ -export * from './AchievementBadges'; -export * from './AvatarCamera'; -export * from './CourseCardSkeleton'; -export * from './CourseViewerSkeleton'; -export * from './DataGridSkeleton'; -export * from './FilterSheet'; -export * from './HealthDashboard'; -export * from './HomeScreenSkeleton'; -export * from './InfiniteVirtualList'; -export * from './MobileFormInput'; -export * from './MobileHeader'; -export * from './MobileProfile'; -export * from './MobileSearch'; -export * from './MobileSettings'; -export * from './NativeToggle'; -export * from './NotificationPrompt'; -export * from './NotificationSettings'; -export * from './OfflineIndicator'; -export * from './OfflineIndicatorProvider'; -export * from './ProfileSkeleton'; -export * from './QRScannerSkeleton'; -export * from './QuizSkeleton'; -export * from './SearchHistory'; -export * from './SearchResultCard'; -export * from './SearchScreenSkeleton'; -export * from './SettingsPicker'; -export * from './SettingsSection'; -export * from './SettingsSkeleton'; -export * from './StatisticsDisplay'; -export * from './SubscriptionSkeleton'; -export * from './SwipeableCoordinator'; -export * from './SwipeableRow'; -export * from './TeamDashboard'; -export * from './VirtualList'; -export * from './VoiceSearch'; -export * from './VirtualList'; -export * from './VoiceSearch'; -export * from './ProfiledScreen'; - +export { AchievementBadges } from './AchievementBadges'; +export { AnalyticsProvider } from './AnalyticsProvider'; +export { BookmarkButton } from './BookmarkButton'; +export { BookmarkList, default as BookmarkListDefault } from './BookmarkList'; +export { CourseCardSkeleton } from './CourseCardSkeleton'; +export { DownloadQueue } from './DownloadQueue'; +export { InfiniteVirtualList } from './InfiniteVirtualList'; +export { MobileFormInput } from './MobileFormInput'; +export { MobileHeader } from './MobileHeader'; +export { MobileProfile } from './MobileProfile'; +export { OfflineIndicatorProvider } from './OfflineIndicatorProvider'; +export { SearchResultItem } from './SearchResultCard'; +export { SearchScreenSkeleton } from './SearchScreenSkeleton'; diff --git a/src/components/ui/Skeleton.stories.tsx b/src/components/ui/Skeleton.stories.tsx index 983aec4f..bdff0fdf 100644 --- a/src/components/ui/Skeleton.stories.tsx +++ b/src/components/ui/Skeleton.stories.tsx @@ -9,7 +9,7 @@ const SkeletonMeta: Meta = { title: 'Components/UI/Skeleton', component: Skeleton, decorators: [ - (Story) => ( + Story => ( diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx index 552bc644..25efe089 100644 --- a/src/components/ui/Skeleton.tsx +++ b/src/components/ui/Skeleton.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef } from 'react'; import { Animated, DimensionValue, StyleSheet, View, ViewStyle } from 'react-native'; + import { useAdaptiveFrameRate } from '../../hooks/useAdaptiveFrameRate'; /** diff --git a/src/config/fonts.ts b/src/config/fonts.ts index 93c72dc0..545fc712 100644 --- a/src/config/fonts.ts +++ b/src/config/fonts.ts @@ -1,6 +1,6 @@ /** * Font Configuration - * + * * Centralized configuration for all fonts used in the application. * This file defines font families, weights, and loading strategies. */ @@ -142,26 +142,27 @@ export const TYPOGRAPHY_PRESETS = { export const FONT_LOADING_CONFIG = { // Critical fonts that must load before app renders critical: ['Inter-Regular', 'Inter-Medium', 'Inter-Bold'], - + // Important fonts that should load soon after important: ['Inter-SemiBold'], - + // Optional fonts that can load on demand optional: [], - + // Loading strategy strategy: 'preload-critical' as const, - + // Fallback settings fallback: 'System', - + // Display strategy display: 'swap' as const, }; // Character sets for subsetting export const CHARACTER_SETS = { - latin: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ ', + latin: + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ ', latinExtended: 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ', cyrillic: 'АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюя', greek: 'ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩαβγδεζηθικλμνξοπρστυφχψω', diff --git a/src/data/sampleCourse.ts b/src/data/sampleCourse.ts index a112f48c..4b9f361a 100644 --- a/src/data/sampleCourse.ts +++ b/src/data/sampleCourse.ts @@ -1,5 +1,5 @@ -import { Course } from '../types/course'; import { getQuizzesForSection } from './sampleQuizzes'; +import { Course } from '../types/course'; /** * Sample course data for demoing the Mobile Course Viewer. diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 326b274e..2cb93d76 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -53,4 +53,3 @@ export { useSearchIndex } from './useSearchIndex'; export type { UseSearchIndexResult } from './useSearchIndex'; export { useAppUpdate } from './useAppUpdate'; export type { UseAppUpdateResult, UseAppUpdateState, UseAppUpdateActions } from './useAppUpdate'; - diff --git a/src/hooks/useAdaptiveTheme.ts b/src/hooks/useAdaptiveTheme.ts index 5cd109ed..60655fa6 100644 --- a/src/hooks/useAdaptiveTheme.ts +++ b/src/hooks/useAdaptiveTheme.ts @@ -2,8 +2,8 @@ import { LightSensor } from 'expo-sensors'; import { useEffect, useRef } from 'react'; import { AppState, type AppStateStatus } from 'react-native'; -import { useSettingsStore } from '../store/settingsStore'; import { useTheme } from '../store'; +import { useSettingsStore } from '../store/settingsStore'; export const DARK_LUX_THRESHOLD = 25; export const LIGHT_LUX_THRESHOLD = 75; @@ -124,4 +124,4 @@ export function useAdaptiveTheme(): void { removeSubscription(); }; }, [adaptiveThemeEnabled, setTheme]); -} \ No newline at end of file +} diff --git a/src/hooks/useAnimationStateMachine.ts b/src/hooks/useAnimationStateMachine.ts index 9e8d7ad9..b23837e8 100644 --- a/src/hooks/useAnimationStateMachine.ts +++ b/src/hooks/useAnimationStateMachine.ts @@ -14,9 +14,9 @@ export type AnimationState = 'CLOSED' | 'OPENING' | 'OPEN' | 'CLOSING'; type Action = 'OPEN' | 'CLOSE' | 'ANIMATION_DONE'; const TRANSITIONS: Record>> = { - CLOSED: { OPEN: 'OPENING' }, + CLOSED: { OPEN: 'OPENING' }, OPENING: { ANIMATION_DONE: 'OPEN', CLOSE: 'CLOSING' }, - OPEN: { CLOSE: 'CLOSING' }, + OPEN: { CLOSE: 'CLOSING' }, CLOSING: { ANIMATION_DONE: 'CLOSED', OPEN: 'OPENING' }, }; diff --git a/src/hooks/useBiometricAuth.ts b/src/hooks/useBiometricAuth.ts index 907b55b9..138844d7 100644 --- a/src/hooks/useBiometricAuth.ts +++ b/src/hooks/useBiometricAuth.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react'; + import { BiometricType, AuthResult } from '../services/mobileAuth'; export function useBiometricAuth() { diff --git a/src/hooks/useCamera.ts b/src/hooks/useCamera.ts index 304ac244..b1b44b0e 100644 --- a/src/hooks/useCamera.ts +++ b/src/hooks/useCamera.ts @@ -1,6 +1,7 @@ import * as ImagePicker from 'expo-image-picker'; import { useCallback, useEffect, useState } from 'react'; import { Platform } from 'react-native'; + import { appLogger } from '../utils/logger'; export enum CameraFallbackType { @@ -45,7 +46,9 @@ export const useCamera = (): UseCameraReturn => { const [hasPermission, setHasPermission] = useState(false); const [capturedImage, setCapturedImage] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [fallbackMode, setFallbackMode] = useState(CameraFallbackType.FULL_CAMERA); + const [fallbackMode, setFallbackMode] = useState( + CameraFallbackType.FULL_CAMERA + ); const [statusMessage, setStatusMessage] = useState('Camera ready'); const degradationStore = useDegradationStore(); @@ -91,13 +94,17 @@ export const useCamera = (): UseCameraReturn => { degradationStore.addNotification({ feature: FeatureType.CAMERA, status: FeatureStatus.PERMISSION_DENIED, - message: 'Camera and photo library permissions were denied. Grant them in Settings to use this feature.', + message: + 'Camera and photo library permissions were denied. Grant them in Settings to use this feature.', }); } return granted || mediaLibraryStatus.granted; } catch (error) { - appLogger.errorSync('[useCamera] Error requesting permissions', error instanceof Error ? error : new Error(String(error))); + appLogger.errorSync( + '[useCamera] Error requesting permissions', + error instanceof Error ? error : new Error(String(error)) + ); degradationStore.setFeatureStatus(FeatureType.CAMERA, FeatureStatus.UNAVAILABLE); setFallbackMode(CameraFallbackType.UNAVAILABLE); setStatusMessage('Camera initialization failed'); @@ -135,14 +142,20 @@ export const useCamera = (): UseCameraReturn => { } return null; } catch (error) { - appLogger.errorSync('[useCamera] Error taking picture', error instanceof Error ? error : new Error(String(error))); + appLogger.errorSync( + '[useCamera] Error taking picture', + error instanceof Error ? error : new Error(String(error)) + ); // If camera operation fails, try falling back to library try { appLogger.infoSync('[useCamera] Camera operation failed, attempting library fallback'); return await pickFromLibrary(); } catch (fallbackError) { - appLogger.errorSync('[useCamera] Fallback to library also failed', fallbackError instanceof Error ? fallbackError : new Error(String(fallbackError))); + appLogger.errorSync( + '[useCamera] Fallback to library also failed', + fallbackError instanceof Error ? fallbackError : new Error(String(fallbackError)) + ); degradationStore.addNotification({ feature: FeatureType.CAMERA, status: FeatureStatus.UNAVAILABLE, @@ -190,7 +203,10 @@ export const useCamera = (): UseCameraReturn => { } return null; } catch (error) { - appLogger.errorSync('[useCamera] Error picking from library', error instanceof Error ? error : new Error(String(error))); + appLogger.errorSync( + '[useCamera] Error picking from library', + error instanceof Error ? error : new Error(String(error)) + ); degradationStore.addNotification({ feature: FeatureType.CAMERA, status: FeatureStatus.UNAVAILABLE, diff --git a/src/hooks/useCoursePagination.ts b/src/hooks/useCoursePagination.ts index b641962f..086dfbe1 100644 --- a/src/hooks/useCoursePagination.ts +++ b/src/hooks/useCoursePagination.ts @@ -1,7 +1,8 @@ +import { useCallback, useEffect, useState } from 'react'; + import { courseApi } from '@/services/api/courseApi'; import { CursorPageRequest } from '@/services/api/cursorPagination'; import { Course } from '@/types/course'; -import { useCallback, useEffect, useState } from 'react'; export interface UseCoursePaginationOptions { initialLimit?: number; @@ -20,13 +21,9 @@ export interface UseCoursePaginationResult { } export function useCoursePagination( - options: UseCoursePaginationOptions = {}, + options: UseCoursePaginationOptions = {} ): UseCoursePaginationResult { - const { - initialLimit = 20, - orderBy = 'id', - direction = 'asc', - } = options; + const { initialLimit = 20, orderBy = 'id', direction = 'asc' } = options; const [items, setItems] = useState([]); const [nextCursor, setNextCursor] = useState(null); @@ -34,37 +31,40 @@ export function useCoursePagination( const [isLoading, setIsLoading] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); - const fetchPage = useCallback(async (cursor?: string) => { - if (isLoading) { - return; - } - - setIsLoading(true); - - const request: CursorPageRequest = { - limit: initialLimit, - cursor, - orderBy, - direction, - }; - - try { - const response = await courseApi.getCoursesPage(request); - - setItems((previous) => { - if (!cursor) { - return response.items; - } - - const existingIds = new Set(previous.map((course) => course.id)); - return [...previous, ...response.items.filter((course) => !existingIds.has(course.id))]; - }); - setNextCursor(response.nextCursor); - setHasMore(response.hasMore); - } finally { - setIsLoading(false); - } - }, [direction, initialLimit, isLoading, orderBy]); + const fetchPage = useCallback( + async (cursor?: string) => { + if (isLoading) { + return; + } + + setIsLoading(true); + + const request: CursorPageRequest = { + limit: initialLimit, + cursor, + orderBy, + direction, + }; + + try { + const response = await courseApi.getCoursesPage(request); + + setItems(previous => { + if (!cursor) { + return response.items; + } + + const existingIds = new Set(previous.map(course => course.id)); + return [...previous, ...response.items.filter(course => !existingIds.has(course.id))]; + }); + setNextCursor(response.nextCursor); + setHasMore(response.hasMore); + } finally { + setIsLoading(false); + } + }, + [direction, initialLimit, isLoading, orderBy] + ); const refresh = useCallback(async () => { setIsRefreshing(true); diff --git a/src/hooks/useCourseProgress.ts b/src/hooks/useCourseProgress.ts index 68dec4e0..6457c4b7 100644 --- a/src/hooks/useCourseProgress.ts +++ b/src/hooks/useCourseProgress.ts @@ -1,10 +1,9 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; - import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useState, useEffect, useCallback, useRef } from 'react'; -import { CourseProgress, LessonProgress, Note, Course } from '../types/course'; import apiClient from '../services/api/axios.config'; import { useCourseProgressStore } from '../store/courseProgressStore'; +import { CourseProgress, LessonProgress, Note, Course } from '../types/course'; import logger from '../utils/logger'; const PROGRESS_STORAGE_KEY = 'course_progress'; @@ -53,17 +52,20 @@ export function useCourseProgress({ const storageKey = `${PROGRESS_STORAGE_KEY}_${courseId}`; - const buildInitialProgress = useCallback((): CourseProgress => ({ - courseId, - currentLessonId: course?.sections[0]?.lessons[0]?.id ?? '', - currentSectionId: course?.sections[0]?.id ?? '', - lessons: {}, - quizzes: {}, - overallProgress: 0, - lastAccessed: new Date().toISOString(), - bookmarks: [], - notes: {}, - }), [courseId, course]); + const buildInitialProgress = useCallback( + (): CourseProgress => ({ + courseId, + currentLessonId: course?.sections[0]?.lessons[0]?.id ?? '', + currentSectionId: course?.sections[0]?.id ?? '', + lessons: {}, + quizzes: {}, + overallProgress: 0, + lastAccessed: new Date().toISOString(), + bookmarks: [], + notes: {}, + }), + [courseId, course] + ); // ── Load from AsyncStorage on init ──────────────────────────────────────── @@ -71,9 +73,7 @@ export function useCourseProgress({ try { setIsLoading(true); const stored = await AsyncStorage.getItem(storageKey); - const parsed: CourseProgress = stored - ? JSON.parse(stored) - : buildInitialProgress(); + const parsed: CourseProgress = stored ? JSON.parse(stored) : buildInitialProgress(); if (!stored) { await AsyncStorage.setItem(storageKey, JSON.stringify(parsed)); @@ -93,36 +93,45 @@ export function useCourseProgress({ // ── Persist to AsyncStorage + update store ──────────────────────────────── - const saveProgress = useCallback(async (updated: CourseProgress) => { - try { - await AsyncStorage.setItem(storageKey, JSON.stringify(updated)); - setFullProgress(updated); - setCourseProgress(courseId, updated); - } catch (error) { - logger.error('useCourseProgress: saveProgress error', error); - } - }, [storageKey, courseId, setCourseProgress]); + const saveProgress = useCallback( + async (updated: CourseProgress) => { + try { + await AsyncStorage.setItem(storageKey, JSON.stringify(updated)); + setFullProgress(updated); + setCourseProgress(courseId, updated); + } catch (error) { + logger.error('useCourseProgress: saveProgress error', error); + } + }, + [storageKey, courseId, setCourseProgress] + ); // ── Debounced server sync (PATCH /api/progress/:courseId) ───────────────── - const syncProgress = useCallback(async (progressToSync?: CourseProgress) => { - const data = progressToSync ?? fullProgress; - if (!data) return; - try { - await apiClient.patch(`/api/progress/${courseId}`, data); - } catch (error: any) { - if (error.code !== 'ERR_NETWORK' && error.message !== 'Network Error') { - logger.error('useCourseProgress: syncProgress error', error); + const syncProgress = useCallback( + async (progressToSync?: CourseProgress) => { + const data = progressToSync ?? fullProgress; + if (!data) return; + try { + await apiClient.patch(`/api/progress/${courseId}`, data); + } catch (error: any) { + if (error.code !== 'ERR_NETWORK' && error.message !== 'Network Error') { + logger.error('useCourseProgress: syncProgress error', error); + } } - } - }, [courseId, fullProgress]); + }, + [courseId, fullProgress] + ); - const scheduleSyncDebounced = useCallback((data: CourseProgress) => { - if (syncTimerRef.current) clearTimeout(syncTimerRef.current); - syncTimerRef.current = setTimeout(() => { - syncProgress(data); - }, SYNC_DEBOUNCE_MS); - }, [syncProgress]); + const scheduleSyncDebounced = useCallback( + (data: CourseProgress) => { + if (syncTimerRef.current) clearTimeout(syncTimerRef.current); + syncTimerRef.current = setTimeout(() => { + syncProgress(data); + }, SYNC_DEBOUNCE_MS); + }, + [syncProgress] + ); // ── calculateOverallProgress ────────────────────────────────────────────── @@ -130,7 +139,7 @@ export function useCourseProgress({ if (!course || !fullProgress) return 0; const total = course.totalLessons; if (total === 0) return 0; - const completed = Object.values(fullProgress.lessons).filter((l) => l.completed).length; + const completed = Object.values(fullProgress.lessons).filter(l => l.completed).length; return Math.round((completed / total) * 100); }, [course, fullProgress]); @@ -160,7 +169,7 @@ export function useCourseProgress({ await saveProgress(updated); scheduleSyncDebounced(updated); }, - [fullProgress, saveProgress, calculateOverallProgress, scheduleSyncDebounced], + [fullProgress, saveProgress, calculateOverallProgress, scheduleSyncDebounced] ); // ── Simplified updateProgress (issue #152 requirement) ─────────────────── @@ -169,7 +178,7 @@ export function useCourseProgress({ (lessonId: string, position: number) => { updateLessonProgress(lessonId, { lastPosition: position }); }, - [updateLessonProgress], + [updateLessonProgress] ); // ── markLessonComplete ──────────────────────────────────────────────────── @@ -181,7 +190,7 @@ export function useCourseProgress({ completedAt: new Date().toISOString(), }); }, - [updateLessonProgress], + [updateLessonProgress] ); // ── setCurrentLesson ────────────────────────────────────────────────────── @@ -198,7 +207,7 @@ export function useCourseProgress({ await saveProgress(updated); scheduleSyncDebounced(updated); }, - [fullProgress, saveProgress, scheduleSyncDebounced], + [fullProgress, saveProgress, scheduleSyncDebounced] ); // ── updateLastPosition ──────────────────────────────────────────────────── @@ -211,7 +220,7 @@ export function useCourseProgress({ timeSpent: (existing?.timeSpent ?? 0) + 1, }); }, - [fullProgress, updateLessonProgress], + [fullProgress, updateLessonProgress] ); // ── addBookmark ─────────────────────────────────────────────────────────── @@ -226,7 +235,7 @@ export function useCourseProgress({ await saveProgress(updated); scheduleSyncDebounced(updated); }, - [fullProgress, saveProgress, scheduleSyncDebounced], + [fullProgress, saveProgress, scheduleSyncDebounced] ); // ── removeBookmark ──────────────────────────────────────────────────────── @@ -236,12 +245,12 @@ export function useCourseProgress({ if (!fullProgress) return; const updated: CourseProgress = { ...fullProgress, - bookmarks: fullProgress.bookmarks.filter((id) => id !== lessonId), + bookmarks: fullProgress.bookmarks.filter(id => id !== lessonId), }; await saveProgress(updated); scheduleSyncDebounced(updated); }, - [fullProgress, saveProgress, scheduleSyncDebounced], + [fullProgress, saveProgress, scheduleSyncDebounced] ); // ── addNote ─────────────────────────────────────────────────────────────── @@ -268,7 +277,7 @@ export function useCourseProgress({ scheduleSyncDebounced(updated); return note; }, - [fullProgress, saveProgress, scheduleSyncDebounced], + [fullProgress, saveProgress, scheduleSyncDebounced] ); // ── updateNote ──────────────────────────────────────────────────────────── @@ -280,15 +289,15 @@ export function useCourseProgress({ ...fullProgress, notes: { ...fullProgress.notes, - [lessonId]: (fullProgress.notes[lessonId] ?? []).map((n) => - n.id === noteId ? { ...n, content, updatedAt: new Date().toISOString() } : n, + [lessonId]: (fullProgress.notes[lessonId] ?? []).map(n => + n.id === noteId ? { ...n, content, updatedAt: new Date().toISOString() } : n ), }, }; await saveProgress(updated); scheduleSyncDebounced(updated); }, - [fullProgress, saveProgress, scheduleSyncDebounced], + [fullProgress, saveProgress, scheduleSyncDebounced] ); // ── deleteNote ──────────────────────────────────────────────────────────── @@ -300,13 +309,13 @@ export function useCourseProgress({ ...fullProgress, notes: { ...fullProgress.notes, - [lessonId]: (fullProgress.notes[lessonId] ?? []).filter((n) => n.id !== noteId), + [lessonId]: (fullProgress.notes[lessonId] ?? []).filter(n => n.id !== noteId), }, }; await saveProgress(updated); scheduleSyncDebounced(updated); }, - [fullProgress, saveProgress, scheduleSyncDebounced], + [fullProgress, saveProgress, scheduleSyncDebounced] ); // ── Effects ─────────────────────────────────────────────────────────────── diff --git a/src/hooks/useCustomFonts.ts b/src/hooks/useCustomFonts.ts index cdc51b2a..7b9e48e1 100644 --- a/src/hooks/useCustomFonts.ts +++ b/src/hooks/useCustomFonts.ts @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react'; -import { loadAsync } from 'expo-font'; import { Asset } from 'expo-asset'; +import { loadAsync } from 'expo-font'; +import { useEffect, useState } from 'react'; import { Platform } from 'react-native'; // Font configuration @@ -90,7 +90,7 @@ async function loadSingleFont(config: FontConfig): Promise { fontCache.setLoaded(config.name); return true; }) - .catch((error) => { + .catch(error => { console.error(`Failed to load font ${config.name}:`, error); return false; }); @@ -110,7 +110,7 @@ async function loadFontsWithProgress( const failed: string[] = []; const results = await Promise.allSettled( - configs.map(async (config) => { + configs.map(async config => { const success = await loadSingleFont(config); completed++; onProgress?.((completed / total) * 100); @@ -160,7 +160,7 @@ export function useCustomFonts( try { setStatus({ loaded: false, error: null, progress: 0 }); - const { loaded, failed } = await loadFontsWithProgress(fontConfigs, (progress) => { + const { loaded, failed } = await loadFontsWithProgress(fontConfigs, progress => { if (mounted) { setStatus({ loaded: false, error: null, progress }); onProgress?.(progress); @@ -206,7 +206,7 @@ export function useLazyFont(config: FontConfig) { const load = async () => { setStatus({ loaded: false, error: null, progress: 0 }); - + try { const success = await loadSingleFont(config); if (success) { @@ -244,13 +244,13 @@ export async function preloadCriticalFonts() { export const fontUtils = { // Check if a specific font is loaded isLoaded: (fontName: string): boolean => fontCache.isLoaded(fontName), - + // Clear font cache (useful for testing or font updates) clearCache: (): void => fontCache.clear(), - + // Get all loaded fonts getLoadedFonts: (): string[] => Array.from(fontCache.cache.keys()), - + // Get font loading status getLoadingStatus: (fontName: string): { loaded: boolean; loading: boolean } => ({ loaded: fontCache.isLoaded(fontName), diff --git a/src/hooks/useDashboardMetrics.ts b/src/hooks/useDashboardMetrics.ts index 245a2d91..31bc4f29 100644 --- a/src/hooks/useDashboardMetrics.ts +++ b/src/hooks/useDashboardMetrics.ts @@ -6,9 +6,11 @@ */ import { useEffect } from 'react'; -import type { DashboardAlert, DashboardSnapshot } from '../services/metricsService'; + import { DashboardView, useMetricsStore } from '../store/metricsStore'; +import type { DashboardAlert, DashboardSnapshot } from '../services/metricsService'; + export interface UseDashboardMetricsResult { snapshot: DashboardSnapshot | null; isRefreshing: boolean; diff --git a/src/hooks/useDataGrid.tsx b/src/hooks/useDataGrid.tsx index f58296cf..067f5608 100644 --- a/src/hooks/useDataGrid.tsx +++ b/src/hooks/useDataGrid.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useReducer } from 'react'; +import { BatchProgress, batchExportData } from '../services/batchDataProcessor'; import { ColumnDef, EditingCell, @@ -18,7 +19,6 @@ import { toggleSortDirection, validateCellValue, } from '../utils/gridUtils'; -import { BatchProgress, batchExportData } from '../services/batchDataProcessor'; import { logger } from '../utils/logger'; // ─── State shape ───────────────────────────────────────────────────────────── diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts index 1f163742..d585fe46 100644 --- a/src/hooks/useDebounce.ts +++ b/src/hooks/useDebounce.ts @@ -3,7 +3,7 @@ import { useEffect, useState, useRef, useCallback } from 'react'; /** * A hook that returns a debounced version of the provided value. * Useful for debouncing values that change rapidly, such as search text. - * + * * @param value The value to debounce * @param delay The delay in milliseconds * @returns The debounced value @@ -27,7 +27,7 @@ export function useDebounce(value: T, delay: number): T { /** * A hook that returns a debounced version of the provided callback function. * Useful for debouncing rapid event handlers, such as scroll events. - * + * * @param callback The callback function to debounce * @param delay The delay in milliseconds * @returns A debounced version of the callback diff --git a/src/hooks/useDeepLink.ts b/src/hooks/useDeepLink.ts index 7e4e24e1..2c136cf2 100644 --- a/src/hooks/useDeepLink.ts +++ b/src/hooks/useDeepLink.ts @@ -1,10 +1,13 @@ import { useEffect, useState } from 'react'; + import { getInitialDeepLink, subscribeToDeepLinks } from '../services/deepLinking'; + import type { ParsedDeepLink } from '../utils/linkParser'; -export function useDeepLink( - onDeepLink?: (deepLink: ParsedDeepLink) => void -): { deepLink: ParsedDeepLink | null; hasDeepLink: boolean } { +export function useDeepLink(onDeepLink?: (deepLink: ParsedDeepLink) => void): { + deepLink: ParsedDeepLink | null; + hasDeepLink: boolean; +} { const [deepLink, setDeepLink] = useState(null); useEffect(() => { @@ -22,7 +25,7 @@ export function useDeepLink( initialize(); - const unsubscribe = subscribeToDeepLinks((payload) => { + const unsubscribe = subscribeToDeepLinks(payload => { if (!isMounted) { return; } diff --git a/src/hooks/useDeviceUiComplexity.ts b/src/hooks/useDeviceUiComplexity.ts index 2df36477..1edbe90a 100644 --- a/src/hooks/useDeviceUiComplexity.ts +++ b/src/hooks/useDeviceUiComplexity.ts @@ -1,9 +1,9 @@ import { useLowPowerMode } from 'expo-battery'; import * as Device from 'expo-device'; import { useEffect, useMemo } from 'react'; -import { useDeviceStore } from '../store/deviceStore'; import { mobileAnalyticsService } from '../services/mobileAnalytics'; +import { useDeviceStore } from '../store/deviceStore'; import { AnalyticsEvent } from '../utils/trackingEvents'; export type UiComplexityLevel = 'low' | 'mid' | 'high'; diff --git a/src/hooks/useDownloads.ts b/src/hooks/useDownloads.ts index 848f05ec..7b7368ee 100644 --- a/src/hooks/useDownloads.ts +++ b/src/hooks/useDownloads.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; + import downloadManager, { DownloadTask } from '../services/downloadManager'; /** @@ -9,7 +10,7 @@ export function useDownloads() { const [totalSize, setTotalSize] = useState(0); useEffect(() => { - const unsubscribe = downloadManager.subscribe((updatedTasks) => { + const unsubscribe = downloadManager.subscribe(updatedTasks => { setTasks(updatedTasks); const size = updatedTasks.reduce((acc, t) => acc + t.downloadedSize, 0); setTotalSize(size); @@ -38,4 +39,4 @@ export function useDownloads() { removeDownload, clearAll, }; -} \ No newline at end of file +} diff --git a/src/hooks/useGestures.ts b/src/hooks/useGestures.ts index a789ab55..b20fabff 100644 --- a/src/hooks/useGestures.ts +++ b/src/hooks/useGestures.ts @@ -1,7 +1,8 @@ import * as React from 'react'; -import type { GestureResponderEvent, ViewProps } from 'react-native'; import { AccessibilityInfo } from 'react-native'; +import type { GestureResponderEvent, ViewProps } from 'react-native'; + /** * A tiny gesture "arbiter" to prevent recognizers (swipe/pinch/long-press/etc.) * from interfering with each other. @@ -74,10 +75,10 @@ export function useGestures(options: UseGesturesOptions = {}): GestureCoordinato // If you need pre-emption, add an explicit "candidate" state. return false; }, - [disabled], + [disabled] ); - const release = React.useCallback((id) => { + const release = React.useCallback(id => { if (activeIdRef.current === id) { activeIdRef.current = null; } @@ -96,7 +97,7 @@ export function useGestures(options: UseGesturesOptions = {}): GestureCoordinato isActive, getActiveId, }), - [tryClaim, release, hasActiveGesture, isActive, getActiveId], + [tryClaim, release, hasActiveGesture, isActive, getActiveId] ); } @@ -152,15 +153,15 @@ export function useDoubleTap(options: UseDoubleTapOptions) { React.useEffect(() => { let mounted = true; - AccessibilityInfo.isScreenReaderEnabled().then((enabled) => { + AccessibilityInfo.isScreenReaderEnabled().then(enabled => { if (mounted) setScreenReaderEnabled(enabled); }); - const sub = AccessibilityInfo.addEventListener?.('screenReaderChanged', (enabled) => { + const sub = AccessibilityInfo.addEventListener?.('screenReaderChanged', enabled => { setScreenReaderEnabled(Boolean(enabled)); }); return () => { mounted = false; - // eslint-disable-next-line @typescript-eslint/no-explicit-any + (sub as any)?.remove?.(); }; }, []); @@ -197,10 +198,10 @@ export function useDoubleTap(options: UseDoubleTapOptions) { const disabled = disableWhenScreenReaderEnabled && screenReaderEnabled; return { - onStartShouldSetResponder: (e) => !disabled && e.nativeEvent.touches.length === 1, - onMoveShouldSetResponder: (e) => !disabled && e.nativeEvent.touches.length === 1, + onStartShouldSetResponder: e => !disabled && e.nativeEvent.touches.length === 1, + onMoveShouldSetResponder: e => !disabled && e.nativeEvent.touches.length === 1, onResponderTerminationRequest: () => true, - onResponderGrant: (e) => { + onResponderGrant: e => { if (disabled) return; const { pageX: x, pageY: y } = e.nativeEvent; startRef.current = { x, y }; @@ -214,7 +215,7 @@ export function useDoubleTap(options: UseDoubleTapOptions) { const maxSq = maxMoveDistance * maxMoveDistance; if (distanceSq(pageX, pageY, s.x, s.y) > maxSq) movedTooFarRef.current = true; }, - onResponderRelease: (e) => { + onResponderRelease: e => { if (disabled) return; const { pageX: x, pageY: y } = e.nativeEvent; const now = Date.now(); @@ -236,7 +237,7 @@ export function useDoubleTap(options: UseDoubleTapOptions) { tap1Ref.current = { t: now, x, y }; clearTimer(); startTimeRef.current = performance.now(); - + // Use requestAnimationFrame for frame-synced timing const checkDuration = (timestamp: number) => { const elapsed = timestamp - (startTimeRef.current ?? timestamp); @@ -250,7 +251,7 @@ export function useDoubleTap(options: UseDoubleTapOptions) { rafRef.current = requestAnimationFrame(checkDuration); } }; - + rafRef.current = requestAnimationFrame(checkDuration); }, onResponderTerminate: () => reset(), @@ -270,4 +271,3 @@ export function useDoubleTap(options: UseDoubleTapOptions) { return { doubleTapHandlers: handlers, resetDoubleTap: reset }; } - diff --git a/src/hooks/useHealthDashboard.ts b/src/hooks/useHealthDashboard.ts index d6b929a2..3395e2b9 100644 --- a/src/hooks/useHealthDashboard.ts +++ b/src/hooks/useHealthDashboard.ts @@ -9,14 +9,12 @@ */ import { useCallback, useEffect, useRef } from 'react'; + +import { AlertThresholds, healthMetricsService } from '../services/healthMetrics'; import { - AlertThresholds, - healthMetricsService, -} from '../services/healthMetrics'; -import { - selectOverallStatus, - selectVisibleAlerts, - useHealthDashboardStore, + selectOverallStatus, + selectVisibleAlerts, + useHealthDashboardStore, } from '../store/healthDashboardStore'; import { appLogger } from '../utils/logger'; diff --git a/src/hooks/useInAppPurchase.ts b/src/hooks/useInAppPurchase.ts index d9cd54dc..6278c3cd 100644 --- a/src/hooks/useInAppPurchase.ts +++ b/src/hooks/useInAppPurchase.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import logger from '../utils/logger'; + import { mobilePaymentsService, SubscriptionPlan, @@ -8,6 +8,7 @@ import { SUBSCRIPTION_PLANS, PRODUCT_IDS, } from '../services/mobilePayments'; +import logger from '../utils/logger'; // ─── State shape ────────────────────────────────────────────────────────────── @@ -105,57 +106,50 @@ export const useInAppPurchase = (): UseInAppPurchase => { // ── Purchase subscription ──────────────────────────────────────────────── - const purchaseSubscription = useCallback( - async (productId: string): Promise => { - setIsPurchasing(true); - setError(null); - try { - const record = - await mobilePaymentsService.purchaseSubscription(productId); - const plan = SUBSCRIPTION_PLANS.find((p) => p.productId === productId); - if (plan) setCurrentTier(plan.tier); - setPurchaseHistory((prev) => [record, ...prev]); - setPurchaseSuccess(true); - setTimeout(() => setPurchaseSuccess(false), 3000); - return true; - } catch (err: any) { - const msg: string = err?.message ?? ''; - // User-cancelled flows should not show an error banner - if (!msg.toLowerCase().includes('cancel')) { - setError(msg || 'Purchase failed. Please try again.'); - } - return false; - } finally { - setIsPurchasing(false); + const purchaseSubscription = useCallback(async (productId: string): Promise => { + setIsPurchasing(true); + setError(null); + try { + const record = await mobilePaymentsService.purchaseSubscription(productId); + const plan = SUBSCRIPTION_PLANS.find(p => p.productId === productId); + if (plan) setCurrentTier(plan.tier); + setPurchaseHistory(prev => [record, ...prev]); + setPurchaseSuccess(true); + setTimeout(() => setPurchaseSuccess(false), 3000); + return true; + } catch (err: any) { + const msg: string = err?.message ?? ''; + // User-cancelled flows should not show an error banner + if (!msg.toLowerCase().includes('cancel')) { + setError(msg || 'Purchase failed. Please try again.'); } - }, - [], - ); + return false; + } finally { + setIsPurchasing(false); + } + }, []); // ── Purchase one-time product ──────────────────────────────────────────── - const purchaseProduct = useCallback( - async (productId: string): Promise => { - setIsPurchasing(true); - setError(null); - try { - const record = await mobilePaymentsService.purchaseProduct(productId); - setPurchaseHistory((prev) => [record, ...prev]); - setPurchaseSuccess(true); - setTimeout(() => setPurchaseSuccess(false), 3000); - return true; - } catch (err: any) { - const msg: string = err?.message ?? ''; - if (!msg.toLowerCase().includes('cancel')) { - setError(msg || 'Purchase failed. Please try again.'); - } - return false; - } finally { - setIsPurchasing(false); + const purchaseProduct = useCallback(async (productId: string): Promise => { + setIsPurchasing(true); + setError(null); + try { + const record = await mobilePaymentsService.purchaseProduct(productId); + setPurchaseHistory(prev => [record, ...prev]); + setPurchaseSuccess(true); + setTimeout(() => setPurchaseSuccess(false), 3000); + return true; + } catch (err: any) { + const msg: string = err?.message ?? ''; + if (!msg.toLowerCase().includes('cancel')) { + setError(msg || 'Purchase failed. Please try again.'); } - }, - [], - ); + return false; + } finally { + setIsPurchasing(false); + } + }, []); // ── Restore purchases ──────────────────────────────────────────────────── diff --git a/src/hooks/useInAppReview.ts b/src/hooks/useInAppReview.ts index 75070171..7cee6a66 100644 --- a/src/hooks/useInAppReview.ts +++ b/src/hooks/useInAppReview.ts @@ -6,21 +6,21 @@ import { appLogger } from '../utils/logger'; /** * Hook for managing in-app review requests. - * + * * This hook provides: * - Easy access to review request functionality * - Automatic metric tracking * - Review eligibility checking - * + * * Usage: * ```typescript * const { requestReview, isSupported, canRequestReview } = useInAppReview(); - * + * * // After a positive user experience * const handleCourseComplete = async () => { * await requestReview(ReviewTrigger.COURSE_MILESTONE); * }; - * + * * // Check if review is supported * if (isSupported) { * // Show "Rate Us" button @@ -30,9 +30,9 @@ import { appLogger } from '../utils/logger'; export function useInAppReview() { const [isSupported, setIsSupported] = useState(false); const [isLoading, setIsLoading] = useState(false); - - const getMetrics = useReviewStore((state) => state.getMetrics); - const recordReviewRequest = useReviewStore((state) => state.recordReviewRequest); + + const getMetrics = useReviewStore(state => state.getMetrics); + const recordReviewRequest = useReviewStore(state => state.recordReviewRequest); // Check if in-app review is supported on this device useEffect(() => { @@ -61,7 +61,7 @@ export function useInAppReview() { /** * Request an app store review at an optimal moment. - * + * * @param trigger The positive experience that triggered this request * @returns Result indicating whether the prompt was shown */ @@ -79,7 +79,7 @@ export function useInAppReview() { return result; } catch (error) { appLogger.error('useInAppReview: Failed to request review', error); - + const errorResult: ReviewRequestResult = { shown: false, reason: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, @@ -88,7 +88,7 @@ export function useInAppReview() { }; recordReviewRequest(trigger, false, errorResult.reason); - + return errorResult; } finally { setIsLoading(false); @@ -139,27 +139,29 @@ export function useInAppReview() { /** * Hook for tracking user engagement metrics that influence review eligibility. - * + * * This hook provides convenient methods to update metrics when positive * experiences occur in the app. - * + * * Usage: * ```typescript * const { trackCourseComplete, trackPerfectQuiz, trackAchievement } = useReviewMetrics(); - * + * * // After user completes a course * trackCourseComplete(); - * + * * // After user gets a perfect quiz score * trackPerfectQuiz(); * ``` */ export function useReviewMetrics() { - const incrementCoursesCompleted = useReviewStore((state) => state.incrementCoursesCompleted); - const incrementSessionCount = useReviewStore((state) => state.incrementSessionCount); - const incrementAchievementsUnlocked = useReviewStore((state) => state.incrementAchievementsUnlocked); - const setLearningStreak = useReviewStore((state) => state.setLearningStreak); - const incrementPerfectQuizScores = useReviewStore((state) => state.incrementPerfectQuizScores); + const incrementCoursesCompleted = useReviewStore(state => state.incrementCoursesCompleted); + const incrementSessionCount = useReviewStore(state => state.incrementSessionCount); + const incrementAchievementsUnlocked = useReviewStore( + state => state.incrementAchievementsUnlocked + ); + const setLearningStreak = useReviewStore(state => state.setLearningStreak); + const incrementPerfectQuizScores = useReviewStore(state => state.incrementPerfectQuizScores); const trackCourseComplete = useCallback(() => { incrementCoursesCompleted(); diff --git a/src/hooks/useKeyboardDelegate.ts b/src/hooks/useKeyboardDelegate.ts index 6e106b96..4bca5663 100644 --- a/src/hooks/useKeyboardDelegate.ts +++ b/src/hooks/useKeyboardDelegate.ts @@ -48,9 +48,7 @@ export interface KeyboardDelegateOptions { * * @param options - Optional show/hide callbacks for side-effects. */ -export function useKeyboardDelegate( - options: KeyboardDelegateOptions = {} -): KeyboardState { +export function useKeyboardDelegate(options: KeyboardDelegateOptions = {}): KeyboardState { const { onShow, onHide } = options; const [state, setState] = useState({ @@ -62,8 +60,12 @@ export function useKeyboardDelegate( // Keep callback refs stable so the effect doesn't re-run on every render const onShowRef = useRef(onShow); const onHideRef = useRef(onHide); - useEffect(() => { onShowRef.current = onShow; }, [onShow]); - useEffect(() => { onHideRef.current = onHide; }, [onHide]); + useEffect(() => { + onShowRef.current = onShow; + }, [onShow]); + useEffect(() => { + onHideRef.current = onHide; + }, [onHide]); useEffect(() => { // Use the correct event names per platform diff --git a/src/hooks/useLocation.ts b/src/hooks/useLocation.ts index 1bff6402..879a15fc 100644 --- a/src/hooks/useLocation.ts +++ b/src/hooks/useLocation.ts @@ -6,15 +6,16 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import locationService, { - LocationData, - LocationSourceType, - GetPositionOptions, - Position + +import locationService, { + LocationData, + LocationSourceType, + GetPositionOptions, + Position, } from '../services/locationService'; // Standardized service import target import { useDegradationStore } from '../store/degradationStore'; -import { appLogger } from '../utils/logger'; import { Coordinates, LocationPrecision } from '../utils/geoUtils'; +import { appLogger } from '../utils/logger'; interface UseLocationReturn { /** Native geographic position details (lat, lng, timestamp) */ @@ -57,7 +58,7 @@ export const useLocation = (defaultOptions: GetPositionOptions = {}): UseLocatio const [statusMessage, setStatusMessage] = useState(''); const degradationStore = useDegradationStore(); - + // Safe persistence for configuration changes across renders without re-triggering hooks const optionsRef = useRef(defaultOptions); useEffect(() => { @@ -82,66 +83,75 @@ export const useLocation = (defaultOptions: GetPositionOptions = {}): UseLocatio /** * Refresh current location with performance options and fallback chain */ - const refresh = useCallback(async (overrides?: GetPositionOptions): Promise => { - setLoading(true); - setError(null); - try { - appLogger.infoSync('[useLocation] Refreshing location position mapping'); - - // Fetch underlying position using main-branch configuration merges - const nativePosition = await locationService.getCurrentPosition({ - ...optionsRef.current, - ...overrides, - }); - setPosition(nativePosition); - - // Pass down parameters to downstream graceful degradation engines - const locationData = await locationService.getLocationWithFallback(manualLocation); - - if (locationData) { - setLocation(locationData); - setStatusMessage(locationService.getStatusMessage(locationData)); - - if (locationData.source === LocationSourceType.MANUAL && locationData.address) { - setManualLocationState(locationData.address); + const refresh = useCallback( + async (overrides?: GetPositionOptions): Promise => { + setLoading(true); + setError(null); + try { + appLogger.infoSync('[useLocation] Refreshing location position mapping'); + + // Fetch underlying position using main-branch configuration merges + const nativePosition = await locationService.getCurrentPosition({ + ...optionsRef.current, + ...overrides, + }); + setPosition(nativePosition); + + // Pass down parameters to downstream graceful degradation engines + const locationData = await locationService.getLocationWithFallback(manualLocation); + + if (locationData) { + setLocation(locationData); + setStatusMessage(locationService.getStatusMessage(locationData)); + + if (locationData.source === LocationSourceType.MANUAL && locationData.address) { + setManualLocationState(locationData.address); + } + + const degradedMode = locationData.source !== LocationSourceType.GPS; + setIsDegraded(degradedMode); + degradationStore.setFeatureStatus('location', degradedMode ? 'degraded' : 'available'); + } else { + setLocation(null); + setIsDegraded(true); + setStatusMessage('Please enter your location manually'); } - const degradedMode = locationData.source !== LocationSourceType.GPS; - setIsDegraded(degradedMode); - degradationStore.setFeatureStatus('location', degradedMode ? 'degraded' : 'available'); - } else { - setLocation(null); + return nativePosition; + } catch (err) { + appLogger.errorSync( + '[useLocation] Error refreshing location', + err instanceof Error ? err : new Error(String(err)) + ); + setError(err); setIsDegraded(true); - setStatusMessage('Please enter your location manually'); + setStatusMessage('Location refresh failed - please enter manually'); + degradationStore.setFeatureStatus('location', 'degraded'); + return null; + } finally { + setLoading(false); } - - return nativePosition; - } catch (err) { - appLogger.errorSync('[useLocation] Error refreshing location', err instanceof Error ? err : new Error(String(err))); - setError(err); - setIsDegraded(true); - setStatusMessage('Location refresh failed - please enter manually'); - degradationStore.setFeatureStatus('location', 'degraded'); - return null; - } finally { - setLoading(false); - } - }, [manualLocation, degradationStore]); + }, + [manualLocation, degradationStore] + ); /** * Set manual location */ - const handleSetManualLocation = useCallback((address: string): void => { - if (address.trim()) { - const locationData = locationService.setManualLocation(address); - setLocation(locationData); - setManualLocationState(address); - setStatusMessage(`Location saved: ${address}`); - setIsDegraded(true); // Manual fallback triggers a degraded state marker - degradationStore.setFeatureStatus('location', 'degraded'); - appLogger.infoSync('[useLocation] Manual location set', { address }); - } - }, [degradationStore]); + const handleSetManualLocation = useCallback( + (address: string): void => { + if (address.trim()) { + const locationData = locationService.setManualLocation(address); + setLocation(locationData); + setManualLocationState(address); + setStatusMessage(`Location saved: ${address}`); + setIsDegraded(true); // Manual fallback triggers a degraded state marker + degradationStore.setFeatureStatus('location', 'degraded'); + appLogger.infoSync('[useLocation] Manual location set', { address }); + } + }, + [degradationStore] + ); /** * Clear cached location @@ -160,12 +170,12 @@ export const useLocation = (defaultOptions: GetPositionOptions = {}): UseLocatio * nearby queries (same precision cell) issued in the same window. */ const queryNearby = useCallback( - ( + ( coords: Coordinates, query: (c: Coordinates) => Promise, - precision: LocationPrecision = 'coarse', + precision: LocationPrecision = 'coarse' ) => locationService.batchNearbyQuery(coords, query, precision), - [], + [] ); /** @@ -205,4 +215,4 @@ export const useLocation = (defaultOptions: GetPositionOptions = {}): UseLocatio }; }; -export default useLocation; \ No newline at end of file +export default useLocation; diff --git a/src/hooks/useLongPress.ts b/src/hooks/useLongPress.ts index 7e3cd133..a79602f8 100644 --- a/src/hooks/useLongPress.ts +++ b/src/hooks/useLongPress.ts @@ -1,7 +1,8 @@ import * as React from 'react'; -import type { GestureResponderEvent, ViewProps } from 'react-native'; import { Animated, Easing } from 'react-native'; + import type { GestureCoordinator } from './useGestures'; +import type { GestureResponderEvent, ViewProps } from 'react-native'; export interface LongPressInfo { pageX: number; @@ -26,17 +27,16 @@ export interface UseLongPressOptions { id?: string; } -export interface LongPressHandlers - extends Pick< - ViewProps, - | 'onStartShouldSetResponder' - | 'onMoveShouldSetResponder' - | 'onResponderGrant' - | 'onResponderMove' - | 'onResponderRelease' - | 'onResponderTerminate' - | 'onResponderTerminationRequest' - > {} +export interface LongPressHandlers extends Pick< + ViewProps, + | 'onStartShouldSetResponder' + | 'onMoveShouldSetResponder' + | 'onResponderGrant' + | 'onResponderMove' + | 'onResponderRelease' + | 'onResponderTerminate' + | 'onResponderTerminationRequest' +> {} function distanceSq(ax: number, ay: number, bx: number, by: number): number { const dx = ax - bx; @@ -107,10 +107,10 @@ export function useLongPress(options: UseLongPressOptions) { const handlers = React.useMemo(() => { return { - onStartShouldSetResponder: (e) => e.nativeEvent.touches.length === 1, - onMoveShouldSetResponder: (e) => e.nativeEvent.touches.length === 1, + onStartShouldSetResponder: e => e.nativeEvent.touches.length === 1, + onMoveShouldSetResponder: e => e.nativeEvent.touches.length === 1, onResponderTerminationRequest: () => true, - onResponderGrant: (e) => { + onResponderGrant: e => { if (e.nativeEvent.touches.length !== 1) return; const { pageX: x, pageY: y } = e.nativeEvent; @@ -127,11 +127,11 @@ export function useLongPress(options: UseLongPressOptions) { clearTimer(); startTimeRef.current = performance.now(); - + // Use requestAnimationFrame for frame-synced timing const checkDuration = (timestamp: number) => { if (cancelledRef.current || firedRef.current) return; - + const elapsed = timestamp - (startTimeRef.current ?? timestamp); if (elapsed >= durationMs) { // Duration elapsed, trigger long press @@ -150,7 +150,7 @@ export function useLongPress(options: UseLongPressOptions) { rafRef.current = requestAnimationFrame(checkDuration); } }; - + rafRef.current = requestAnimationFrame(checkDuration); }, onResponderMove: (e: GestureResponderEvent) => { @@ -201,4 +201,3 @@ export function useLongPress(options: UseLongPressOptions) { return { longPressHandlers: handlers, pressProgress, resetLongPress: reset }; } - diff --git a/src/hooks/useMemoryMonitor.ts b/src/hooks/useMemoryMonitor.ts index 1a3b821f..43e86050 100644 --- a/src/hooks/useMemoryMonitor.ts +++ b/src/hooks/useMemoryMonitor.ts @@ -1,6 +1,8 @@ +import * as Device from 'expo-device'; import { useEffect, useRef, useState } from 'react'; import { Platform, Alert } from 'react-native'; -import * as Device from 'expo-device'; + +import { mobileAnalyticsService } from '../services/mobileAnalytics'; import logger from '../utils/logger'; import { captureMemorySnapshot, @@ -8,7 +10,6 @@ import { formatBytes, MemorySnapshot, } from '../utils/memoryProfiler'; -import { mobileAnalyticsService } from '../services/mobileAnalytics'; import { AnalyticsEvent } from '../utils/trackingEvents'; interface MemoryMonitorOptions { diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts index be66eb0d..988102bc 100644 --- a/src/hooks/useNetworkStatus.ts +++ b/src/hooks/useNetworkStatus.ts @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback } from 'react'; import { Network } from 'expo-network'; +import { useState, useEffect, useCallback } from 'react'; export type ConnectionType = 'wifi' | 'cellular' | 'none' | 'unknown'; @@ -30,7 +30,7 @@ export function useNetworkStatus() { setIsChecking(true); try { const networkState = await Network.getNetworkStateAsync(); - + // Update network status setNetworkStatus({ isConnected: networkState.isConnected, @@ -150,4 +150,4 @@ export function useConnectionQuality() { }; } -export default useNetworkStatus; \ No newline at end of file +export default useNetworkStatus; diff --git a/src/hooks/useNotificationPermission.ts b/src/hooks/useNotificationPermission.ts index 0312d29d..42918c2c 100644 --- a/src/hooks/useNotificationPermission.ts +++ b/src/hooks/useNotificationPermission.ts @@ -1,13 +1,14 @@ -import { useState, useEffect, useCallback } from 'react'; -import * as Notifications from 'expo-notifications'; import * as Device from 'expo-device'; +import * as Notifications from 'expo-notifications'; +import { useState, useEffect, useCallback } from 'react'; import { Platform, Linking } from 'react-native'; -import logger from '../utils/logger'; + import { registerForPushNotifications, registerTokenWithBackend, } from '../services/pushNotifications'; import { useNotificationStore } from '../store/notificationStore'; +import logger from '../utils/logger'; export type PermissionStatus = 'undetermined' | 'granted' | 'denied'; @@ -118,9 +119,7 @@ export function useNotificationPermission(): UseNotificationPermissionReturn { }; } -function mapPermissionStatus( - status: Notifications.PermissionStatus -): PermissionStatus { +function mapPermissionStatus(status: Notifications.PermissionStatus): PermissionStatus { switch (status) { case Notifications.PermissionStatus.GRANTED: return 'granted'; diff --git a/src/hooks/useOptimizedClipboard.ts b/src/hooks/useOptimizedClipboard.ts index 9b3c3ead..82a74cd6 100644 --- a/src/hooks/useOptimizedClipboard.ts +++ b/src/hooks/useOptimizedClipboard.ts @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { InteractionManager } from 'react-native'; + import { clipboardService, ClipboardOperationMetrics } from '../services/clipboardService'; export interface UseOptimizedClipboardResult { @@ -21,7 +22,7 @@ export function useOptimizedClipboard(): UseOptimizedClipboardResult { const [error, setError] = useState(null); const [clipboardContent, setClipboardContent] = useState(''); const [metrics, setMetrics] = useState(null); - + const isMounted = useRef(true); const successTimeoutRef = useRef(null); @@ -48,19 +49,19 @@ export function useOptimizedClipboard(): UseOptimizedClipboardResult { setIsCopying(true); // Defer the heavy native copy operation to allow React to render the loading spinner - return new Promise((resolve) => { + return new Promise(resolve => { // 1. Run after any ongoing screen animations or interactions InteractionManager.runAfterInteractions(() => { // 2. Wrap in setTimeout(..., 0) to ensure React state update is flushed and rendered first setTimeout(async () => { try { const success = await clipboardService.copyToClipboardAsync(text); - + if (isMounted.current) { setIsCopying(false); setCopySuccess(success); setMetrics(clipboardService.getLastMetrics()); - + // Automatically reset copy success toast after 2 seconds if (successTimeoutRef.current) clearTimeout(successTimeoutRef.current); successTimeoutRef.current = setTimeout(() => { @@ -88,12 +89,12 @@ export function useOptimizedClipboard(): UseOptimizedClipboardResult { setError(null); setIsPasting(true); - return new Promise((resolve) => { + return new Promise(resolve => { InteractionManager.runAfterInteractions(() => { setTimeout(async () => { try { const content = await clipboardService.pasteFromClipboardAsync(); - + if (isMounted.current) { setIsPasting(false); setClipboardContent(content); diff --git a/src/hooks/useOptimizedVideoGestures.tsx b/src/hooks/useOptimizedVideoGestures.tsx index cac7ef69..78a924a0 100644 --- a/src/hooks/useOptimizedVideoGestures.tsx +++ b/src/hooks/useOptimizedVideoGestures.tsx @@ -6,6 +6,7 @@ */ import React, { useCallback, useRef } from 'react'; +import { View, ViewStyle } from 'react-native'; import { Gesture, GestureDetector, gestureHandlerRootHOC } from 'react-native-gesture-handler'; import Animated, { useAnimatedStyle, @@ -14,7 +15,6 @@ import Animated, { interpolate, Extrapolate, } from 'react-native-reanimated'; -import { View, ViewStyle } from 'react-native'; export interface UseOptimizedVideoGesturesOptions { currentPositionMillis: number; @@ -144,7 +144,7 @@ export function useOptimizedVideoGestures(options: UseOptimizedVideoGesturesOpti /** * Wrapper component for easy integration with video-enabled views */ -export function OptimizedVideoGesturesView({ +export const OptimizedVideoGesturesView = ({ options, children, style, @@ -152,7 +152,7 @@ export function OptimizedVideoGesturesView({ options: UseOptimizedVideoGesturesOptions; children?: React.ReactNode; style?: ViewStyle; -}) { +}) => { const { gesture, animatedStyle } = useOptimizedVideoGestures(options); return ( @@ -160,6 +160,6 @@ export function OptimizedVideoGesturesView({ {children} ); -} +}; export default gestureHandlerRootHOC(OptimizedVideoGesturesView); diff --git a/src/hooks/usePendingRequests.ts b/src/hooks/usePendingRequests.ts index cfa03982..03696921 100644 --- a/src/hooks/usePendingRequests.ts +++ b/src/hooks/usePendingRequests.ts @@ -1,11 +1,10 @@ import { useEffect, useState } from 'react'; + import requestQueue, { RequestPriority } from '../services/api/requestQueue'; export function usePendingRequests(priority?: RequestPriority) { const [pendingCount, setPendingCount] = useState(0); - const [byPriority, setByPriority] = useState< - Record - >({ + const [byPriority, setByPriority] = useState>({ critical: 0, high: 0, normal: 0, diff --git a/src/hooks/usePictureInPicture.ts b/src/hooks/usePictureInPicture.ts index ead7c511..fcec1de6 100644 --- a/src/hooks/usePictureInPicture.ts +++ b/src/hooks/usePictureInPicture.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import type { RefObject } from 'react'; import { AppState, Platform } from 'react-native'; + import type { Video } from 'expo-av'; +import type { RefObject } from 'react'; type UsePictureInPictureParams = { videoRef: RefObject