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