diff --git a/components/parallax-scroll-view.tsx b/components/parallax-scroll-view.tsx index cadbc9a..dc131cd 100644 --- a/components/parallax-scroll-view.tsx +++ b/components/parallax-scroll-view.tsx @@ -1,4 +1,3 @@ -import type { PropsWithChildren, ReactElement } from 'react'; import { StyleSheet } from 'react-native'; import Animated, { interpolate, @@ -9,7 +8,10 @@ import Animated, { import { ThemedView } from '@/components/themed-view'; import { useThemeColor } from '@/hooks/use-theme-color'; -import { useTheme } from '@/store'; +import { useDeviceUiComplexity } from '@/hooks/useDeviceUiComplexity'; +import { useTheme } from '@/src/store'; + +import type { PropsWithChildren, ReactElement } from 'react'; const HEADER_HEIGHT = 250; @@ -18,16 +20,16 @@ type Props = PropsWithChildren<{ headerBackgroundColor: { dark: string; light: string }; }>; -export default function ParallaxScrollView({ - children, - headerImage, - headerBackgroundColor, -}: Props) { +const ParallaxScrollView = ({ children, headerImage, headerBackgroundColor }: Props) => { const backgroundColor = useThemeColor({}, 'background'); const colorScheme = useTheme(); const scrollRef = useAnimatedRef(); const scrollOffset = useScrollOffset(scrollRef); + const { shouldDisableHeavyEffects } = useDeviceUiComplexity(); + const headerAnimatedStyle = useAnimatedStyle(() => { + if (shouldDisableHeavyEffects) return {}; + return { transform: [ { @@ -38,7 +40,11 @@ export default function ParallaxScrollView({ ), }, { - scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]), + scale: interpolate( + scrollOffset.value, + [-HEADER_HEIGHT, 0, HEADER_HEIGHT], + [2, 1, 1] + ), }, ], }; @@ -59,10 +65,11 @@ export default function ParallaxScrollView({ > {headerImage} + {children} ); -} +}; const styles = StyleSheet.create({ container: { @@ -79,3 +86,5 @@ const styles = StyleSheet.create({ overflow: 'hidden', }, }); + +export default ParallaxScrollView; \ No newline at end of file diff --git a/docs/UI_COMPLEXITY.md b/docs/UI_COMPLEXITY.md new file mode 100644 index 0000000..c6f7972 --- /dev/null +++ b/docs/UI_COMPLEXITY.md @@ -0,0 +1,66 @@ +# UI Complexity Adaptation (device capability) + +This app adapts UI motion/effects based on device capability to improve performance on low-end devices and provide a richer experience on capable devices. + +## Complexity levels + +The classifier outputs one of: + +- **low** +- **mid** +- **high** + +### Inputs + +The classifier uses: + +- **Device age class** (`expo-device.deviceYearClass`) +- **Total system RAM** (`expo-device.totalMemory`) +- **Power saver** (`expo-battery.useLowPowerMode()`) + +### Thresholds + +Defined in `src/hooks/useDeviceUiComplexity.ts`: + +- **Low-end year threshold**: `deviceYearClass < 2018` +- **Low-end RAM threshold**: `totalMemoryBytes < 2GB` +- **Mid RAM range**: `2GB <= totalMemoryBytes < 4GB` +- **High**: `totalMemoryBytes >= 4GB` (or when RAM is unknown, falls back to high) + +### Power saver behavior + +If **battery saver** is enabled, the classifier forces **low** complexity regardless of RAM/year. + +## Mapping to UI policies + +| Level | `shouldReduceAnimations` | `shouldDisableHeavyEffects` | `animationTargetFPS` | `animationDurationMultiplier` | +| -------- | ------------------------ | --------------------------- | -------------------- | ----------------------------- | +| **low** | `true` | `true` | 30 | 2 | +| **mid** | `true` | `true` | 45 | 1.5 | +| **high** | `false` | `false` | 60 | 1 | + +## Where it's used + +- `useDeviceUiComplexity()` is the unified source of truth. +- `useAdaptiveFrameRate()` is a backwards-compatible wrapper that maps to the legacy `targetFPS` (30|60) and `durationMultiplier` (1|2) API. + +## Components using shouldDisableHeavyEffects + +| Component | Effect gated | +| ------------------------------------------ | ----------------------------------------------------------------------------------- | +| `components/parallax-scroll-view.tsx` | Parallax scroll transform (translateY + scale) disabled on low/mid | +| `src/components/mobile/LessonCarousel.tsx` | LinearGradient on progress bar and nav buttons replaced with flat colour on low/mid | + +## Analytics monitoring + +Every time `useDeviceUiComplexity()` mounts (or the classified level changes), it fires a `device_complexity_assigned` analytics event: + +| Property | Type | Description | +| ---------------------- | -------------------------- | ------------------------------------------------ | +| `complexity_level` | `'low' \| 'mid' \| 'high'` | Assigned complexity tier | +| `is_low_end_device` | `boolean` | True when year class or RAM is below threshold | +| `is_battery_saver` | `boolean` | True when Low Power / Power Saver mode is active | +| `animation_target_fps` | `30 \| 45 \| 60` | Target FPS for this session | +| `device_year_class` | `number \| undefined` | Raw year class from expo-device | + +Use this event to monitor device distribution across your user base and tune thresholds over time. diff --git a/src/__tests__/hooks/useDeviceUiComplexity.test.ts b/src/__tests__/hooks/useDeviceUiComplexity.test.ts new file mode 100644 index 0000000..cd5c1ab --- /dev/null +++ b/src/__tests__/hooks/useDeviceUiComplexity.test.ts @@ -0,0 +1,155 @@ +import { act, renderHook } from '@testing-library/react-native'; +import * as Battery from 'expo-battery'; + +import { useDeviceUiComplexity } from '../../hooks/useDeviceUiComplexity'; +import { mobileAnalyticsService } from '../../services/mobileAnalytics'; +import { AnalyticsEvent } from '../../utils/trackingEvents'; + +jest.mock('../../services/mobileAnalytics', () => ({ + mobileAnalyticsService: { trackEvent: jest.fn() }, +})); + +const mockTrackEvent = mobileAnalyticsService.trackEvent as jest.Mock; +const mockUseLowPowerMode = Battery.useLowPowerMode as jest.Mock; +const GB = 1024 * 1024 * 1024; + +function mockDevice() { + return jest.requireMock('expo-device') as { + deviceYearClass: number | null; + totalMemory: number | null; + [key: string]: unknown; + }; +} + +describe('useDeviceUiComplexity', () => { + beforeEach(() => { + mockDevice().deviceYearClass = 2021; + mockDevice().totalMemory = 4 * GB; + mockUseLowPowerMode.mockReturnValue(false); + mockTrackEvent.mockClear(); + }); + + it('classifies high when battery saver is off and RAM >= 4GB', () => { + const { result } = renderHook(() => useDeviceUiComplexity()); + expect(result.current.complexityLevel).toBe('high'); + expect(result.current.shouldReduceAnimations).toBe(false); + expect(result.current.shouldDisableHeavyEffects).toBe(false); + expect(result.current.animationTargetFPS).toBe(60); + expect(result.current.animationDurationMultiplier).toBe(1); + }); + + it('classifies low when battery saver is enabled', () => { + mockUseLowPowerMode.mockReturnValue(true); + const { result } = renderHook(() => useDeviceUiComplexity()); + expect(result.current.complexityLevel).toBe('low'); + expect(result.current.shouldReduceAnimations).toBe(true); + expect(result.current.shouldDisableHeavyEffects).toBe(true); + expect(result.current.animationTargetFPS).toBe(30); + expect(result.current.animationDurationMultiplier).toBe(2); + }); + + it('classifies low when deviceYearClass is before 2018', () => { + mockDevice().deviceYearClass = 2016; + const { result } = renderHook(() => useDeviceUiComplexity()); + expect(result.current.complexityLevel).toBe('low'); + expect(result.current.isLowEndDevice).toBe(true); + expect(result.current.animationTargetFPS).toBe(30); + }); + + it('classifies low when RAM is under 2GB', () => { + mockDevice().totalMemory = 1 * GB; + const { result } = renderHook(() => useDeviceUiComplexity()); + expect(result.current.complexityLevel).toBe('low'); + expect(result.current.animationTargetFPS).toBe(30); + }); + + it('classifies mid when RAM is between 2GB and 4GB', () => { + mockDevice().totalMemory = 3 * GB; + const { result } = renderHook(() => useDeviceUiComplexity()); + expect(result.current.complexityLevel).toBe('mid'); + expect(result.current.shouldReduceAnimations).toBe(true); + expect(result.current.shouldDisableHeavyEffects).toBe(true); + expect(result.current.animationTargetFPS).toBe(45); + expect(result.current.animationDurationMultiplier).toBe(1.5); + }); + + it('classifies high at RAM exactly 4GB (boundary)', () => { + mockDevice().totalMemory = 4 * GB; + const { result } = renderHook(() => useDeviceUiComplexity()); + expect(result.current.complexityLevel).toBe('high'); + }); + + it('computes frameIntervalMs correctly for each level', () => { + const { result: high } = renderHook(() => useDeviceUiComplexity()); + expect(high.current.frameIntervalMs).toBeCloseTo(1000 / 60, 2); + + mockDevice().totalMemory = 3 * GB; + const { result: mid } = renderHook(() => useDeviceUiComplexity()); + expect(mid.current.frameIntervalMs).toBeCloseTo(1000 / 45, 2); + + mockDevice().deviceYearClass = 2015; + const { result: low } = renderHook(() => useDeviceUiComplexity()); + expect(low.current.frameIntervalMs).toBeCloseTo(1000 / 30, 2); + }); + + describe('analytics monitoring', () => { + it('fires DEVICE_COMPLEXITY_ASSIGNED on mount with correct properties', () => { + const { result } = renderHook(() => useDeviceUiComplexity()); + act(() => {}); + expect(mockTrackEvent).toHaveBeenCalledWith( + AnalyticsEvent.DEVICE_COMPLEXITY_ASSIGNED, + expect.objectContaining({ + complexity_level: result.current.complexityLevel, + is_low_end_device: result.current.isLowEndDevice, + is_battery_saver: result.current.isBatterySaverEnabled, + animation_target_fps: result.current.animationTargetFPS, + }) + ); + }); + + it('fires with complexity_level "low" for a low-end device', () => { + mockDevice().deviceYearClass = 2015; + renderHook(() => useDeviceUiComplexity()); + act(() => {}); + expect(mockTrackEvent).toHaveBeenCalledWith( + AnalyticsEvent.DEVICE_COMPLEXITY_ASSIGNED, + expect.objectContaining({ complexity_level: 'low' }) + ); + }); + + it('fires with complexity_level "mid" for a mid-range device', () => { + mockDevice().totalMemory = 3 * GB; + renderHook(() => useDeviceUiComplexity()); + act(() => {}); + expect(mockTrackEvent).toHaveBeenCalledWith( + AnalyticsEvent.DEVICE_COMPLEXITY_ASSIGNED, + expect.objectContaining({ complexity_level: 'mid' }) + ); + }); + }); + + describe('shouldDisableHeavyEffects', () => { + it('is false on high-end device', () => { + const { result } = renderHook(() => useDeviceUiComplexity()); + expect(result.current.shouldDisableHeavyEffects).toBe(false); + }); + + it('is true on low-end device (year class)', () => { + mockDevice().deviceYearClass = 2015; + const { result } = renderHook(() => useDeviceUiComplexity()); + expect(result.current.shouldDisableHeavyEffects).toBe(true); + }); + + it('is true on mid-range device', () => { + mockDevice().totalMemory = 3 * GB; + const { result } = renderHook(() => useDeviceUiComplexity()); + expect(result.current.shouldDisableHeavyEffects).toBe(true); + }); + + it('is true when battery saver is enabled on high-end device', () => { + mockUseLowPowerMode.mockReturnValue(true); + const { result } = renderHook(() => useDeviceUiComplexity()); + expect(result.current.shouldDisableHeavyEffects).toBe(true); + }); + }); +}); diff --git a/src/components/mobile/LessonCarousel.tsx b/src/components/mobile/LessonCarousel.tsx index ed14ff2..4b28fac 100644 --- a/src/components/mobile/LessonCarousel.tsx +++ b/src/components/mobile/LessonCarousel.tsx @@ -10,6 +10,7 @@ import { View, } from 'react-native'; +import { useDeviceUiComplexity } from '../../hooks/useDeviceUiComplexity'; import { CourseProgress, Lesson } from '../../types/course'; import { useSettingsStore } from '../../store/settingsStore'; @@ -39,6 +40,7 @@ const LessonCarousel = ({ const flatListRef = useRef>(null); const [currentIndex, setCurrentIndex] = useState(0); const progressBarWidth = useRef(new Animated.Value(0)).current; + const { shouldDisableHeavyEffects } = useDeviceUiComplexity(); useEffect(() => { const index = lessons.findIndex(lesson => lesson.id === currentLessonId); @@ -101,12 +103,16 @@ const LessonCarousel = ({ - + {shouldDisableHeavyEffects ? ( + + ) : ( + + )} @@ -191,16 +197,24 @@ const LessonCarousel = ({ {currentIndex === lessons.length - 1 ? ( - - - {isLastLessonInSection ? 'Continue →' : 'Next →'} - - + {shouldDisableHeavyEffects ? ( + + + {isLastLessonInSection ? 'Continue →' : 'Next →'} + + + ) : ( + + + {isLastLessonInSection ? 'Continue →' : 'Next →'} + + + )} ) : ( - - Next → - + {shouldDisableHeavyEffects ? ( + + Next → + + ) : ( + + Next → + + )} )} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 43adc86..e0340ad 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,5 @@ export * from './useAdaptiveFrameRate'; +export * from './useDeviceUiComplexity'; export * from './useAdaptiveTheme'; export * from './useAnalytics'; export { AuthProvider, useAuth } from './useAuth'; diff --git a/src/hooks/useAdaptiveFrameRate.ts b/src/hooks/useAdaptiveFrameRate.ts index 90fb8ca..7a0b2fb 100644 --- a/src/hooks/useAdaptiveFrameRate.ts +++ b/src/hooks/useAdaptiveFrameRate.ts @@ -1,60 +1,38 @@ import { useMemo } from 'react'; -import * as Device from 'expo-device'; -import { useLowPowerMode } from 'expo-battery'; -import { useSettingsStore } from '../store/settingsStore'; -/** Devices made before this year are classified as low-end. */ -const LOW_END_YEAR_CLASS = 2018; - -/** Devices with less than 2 GB RAM are classified as low-end. */ -const LOW_END_MEMORY_BYTES = 2 * 1024 * 1024 * 1024; +import { useDeviceUiComplexity } from './useDeviceUiComplexity'; export interface AdaptiveFrameRateConfig { /** Target frames-per-second for animations. */ targetFPS: 30 | 60; + /** * Multiply animation durations by this value. * 1 = normal (60 fps), 2 = half-speed (30 fps equivalent). */ durationMultiplier: 1 | 2; + /** Milliseconds per frame at the target FPS. */ frameIntervalMs: number; + /** True when the device hardware is below the low-end threshold. */ isLowEndDevice: boolean; + /** True when iOS Low Power Mode or Android Power Saver is active. */ isBatterySaverEnabled: boolean; - /** True when data saver mode is enabled by user preference. */ - isDataSaverEnabled: boolean; + /** True when either condition requires reduced animation complexity. */ shouldReduceAnimations: boolean; } -function detectLowEndDevice(): boolean { - const yearClass = Device.deviceYearClass; - if (yearClass !== null && yearClass < LOW_END_YEAR_CLASS) return true; - - const memory = Device.totalMemory; - if (memory !== null && memory < LOW_END_MEMORY_BYTES) return true; - - return false; -} - /** - * Returns animation configuration adapted to the current device capabilities - * and power-saver state. Use `durationMultiplier` to scale timing values so - * animations run at ~30 fps on low-end or battery-constrained devices. - * - * @example - * const { durationMultiplier } = useAdaptiveFrameRate(); - * Animated.timing(value, { duration: 300 * durationMultiplier, ... }) + * Backwards-compatible wrapper around useDeviceUiComplexity. + * Maps the unified complexity classifier to the legacy 30|60 fps API. */ export function useAdaptiveFrameRate(): AdaptiveFrameRateConfig { - const dataSaverEnabled = useSettingsStore(state => state.dataSaverEnabled); - const isBatterySaverEnabled = useLowPowerMode(); - const isLowEndDevice = useMemo(() => detectLowEndDevice(), []); + const { shouldReduceAnimations, isLowEndDevice, isBatterySaverEnabled } = useDeviceUiComplexity(); return useMemo(() => { - const shouldReduceAnimations = isLowEndDevice || isBatterySaverEnabled || dataSaverEnabled; const targetFPS: 30 | 60 = shouldReduceAnimations ? 30 : 60; const durationMultiplier: 1 | 2 = shouldReduceAnimations ? 2 : 1; @@ -64,8 +42,7 @@ export function useAdaptiveFrameRate(): AdaptiveFrameRateConfig { frameIntervalMs: 1000 / targetFPS, isLowEndDevice, isBatterySaverEnabled, - isDataSaverEnabled: dataSaverEnabled, shouldReduceAnimations, }; - }, [isLowEndDevice, isBatterySaverEnabled, dataSaverEnabled]); + }, [shouldReduceAnimations, isLowEndDevice, isBatterySaverEnabled]); } diff --git a/src/hooks/useDeviceUiComplexity.ts b/src/hooks/useDeviceUiComplexity.ts new file mode 100644 index 0000000..98ee236 --- /dev/null +++ b/src/hooks/useDeviceUiComplexity.ts @@ -0,0 +1,124 @@ +import { useLowPowerMode } from 'expo-battery'; +import * as Device from 'expo-device'; +import { useEffect, useMemo } from 'react'; + +import { mobileAnalyticsService } from '../services/mobileAnalytics'; +import { AnalyticsEvent } from '../utils/trackingEvents'; + +export type UiComplexityLevel = 'low' | 'mid' | 'high'; + +export interface DeviceUiComplexityConfig { + complexityLevel: UiComplexityLevel; + /** True when iOS Low Power Mode or Android Power Saver is active. */ + isBatterySaverEnabled: boolean; + /** True when device hardware is below the "low-end" threshold. */ + isLowEndDevice: boolean; + shouldReduceAnimations: boolean; + shouldDisableHeavyEffects: boolean; + /** Target frames-per-second for animations. */ + animationTargetFPS: 30 | 45 | 60; + /** Multiply animation durations by this value. 1 = normal, 2 = slower. */ + animationDurationMultiplier: 1 | 1.5 | 2; + /** Milliseconds per frame at the chosen FPS. */ + frameIntervalMs: number; + /** Raw classifier inputs for diagnostics & analytics. */ + deviceYearClass: number | null; + totalMemoryBytes: number | null; +} + +// Classification thresholds (documented in docs/UI_COMPLEXITY.md) +const LOW_END_YEAR_CLASS = 2018; +const LOW_END_MEMORY_BYTES = 2 * 1024 * 1024 * 1024; // <2 GB => low +const MID_END_MEMORY_BYTES = 4 * 1024 * 1024 * 1024; // 2–4 GB => mid, >=4 GB => high + +function classifyDeviceLevel(params: { + deviceYearClass: number | null; + totalMemoryBytes: number | null; + isBatterySaverEnabled: boolean; +}): Omit< + DeviceUiComplexityConfig, + 'isBatterySaverEnabled' | 'frameIntervalMs' | 'deviceYearClass' | 'totalMemoryBytes' +> { + const { deviceYearClass, totalMemoryBytes, isBatterySaverEnabled } = params; + + const isYearLow = deviceYearClass !== null && deviceYearClass < LOW_END_YEAR_CLASS; + const isMemoryLow = + totalMemoryBytes !== null && totalMemoryBytes > 0 && totalMemoryBytes < LOW_END_MEMORY_BYTES; + const isLowEndDevice = Boolean(isYearLow || isMemoryLow); + + if (isBatterySaverEnabled || isLowEndDevice) { + return { + complexityLevel: 'low', + isLowEndDevice, + shouldReduceAnimations: true, + shouldDisableHeavyEffects: true, + animationTargetFPS: 30, + animationDurationMultiplier: 2, + }; + } + + const isMemoryMid = + totalMemoryBytes !== null && + totalMemoryBytes >= LOW_END_MEMORY_BYTES && + totalMemoryBytes < MID_END_MEMORY_BYTES; + + if (isMemoryMid) { + return { + complexityLevel: 'mid', + isLowEndDevice, + shouldReduceAnimations: true, + shouldDisableHeavyEffects: true, + animationTargetFPS: 45, + animationDurationMultiplier: 1.5, + }; + } + + return { + complexityLevel: 'high', + isLowEndDevice, + shouldReduceAnimations: false, + shouldDisableHeavyEffects: false, + animationTargetFPS: 60, + animationDurationMultiplier: 1, + }; +} + +/** + * Detect device capability and adapt UI complexity: fewer animations on low-end, + * richer UI on high-end devices. + */ +export function useDeviceUiComplexity(): DeviceUiComplexityConfig { + const isBatterySaverEnabled = useLowPowerMode(); + const deviceYearClass = Device.deviceYearClass; + const totalMemoryBytes = Device.totalMemory; + + const config = useMemo(() => { + const classified = classifyDeviceLevel({ + deviceYearClass, + totalMemoryBytes, + isBatterySaverEnabled, + }); + return { + ...classified, + isBatterySaverEnabled, + frameIntervalMs: 1000 / classified.animationTargetFPS, + deviceYearClass, + totalMemoryBytes, + }; + }, [deviceYearClass, totalMemoryBytes, isBatterySaverEnabled]); + + useEffect(() => { + mobileAnalyticsService.trackEvent(AnalyticsEvent.DEVICE_COMPLEXITY_ASSIGNED, { + complexity_level: config.complexityLevel, + is_low_end_device: config.isLowEndDevice, + is_battery_saver: config.isBatterySaverEnabled, + animation_target_fps: config.animationTargetFPS, + device_year_class: config.deviceYearClass ?? undefined, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.complexityLevel]); + + return config; +} + +export default useDeviceUiComplexity; diff --git a/src/utils/trackingEvents.ts b/src/utils/trackingEvents.ts index 1cbc37b..e4ef497 100644 --- a/src/utils/trackingEvents.ts +++ b/src/utils/trackingEvents.ts @@ -41,6 +41,9 @@ export enum AnalyticsEvent { API_ERROR = 'api_error', CRASH_REPORT = 'crash_report', + // Device capability + DEVICE_COMPLEXITY_ASSIGNED = 'device_complexity_assigned', + // Core Web Vitals WEB_VITALS_LCP = 'web_vitals_lcp', WEB_VITALS_FID = 'web_vitals_fid', @@ -89,4 +92,4 @@ export enum PerformanceMetric { CLS = 'cls', FCP = 'fcp', TTFB = 'ttfb', -} +} \ No newline at end of file