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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions components/parallax-scroll-view.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,38 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
useSharedValue,
} from 'react-native-reanimated';

import { ThemedView } from '@/components/themed-view';
import { useThemeColor } from '@/hooks/use-theme-color';
import { useDeviceUiComplexity } from '@/hooks/useDeviceUiComplexity';
import { useTheme } from '@/store';

import type { PropsWithChildren, ReactElement } from 'react';

const HEADER_HEIGHT = 250;

type Props = PropsWithChildren<{
headerImage: ReactElement;
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<Animated.ScrollView>();
const scrollOffset = useScrollOffset(scrollRef);
const { shouldDisableHeavyEffects } = useDeviceUiComplexity();
const disableEffects = useSharedValue(shouldDisableHeavyEffects);
disableEffects.value = shouldDisableHeavyEffects;

const headerAnimatedStyle = useAnimatedStyle(() => {
if (disableEffects.value) return {};

return {
transform: [
{
Expand Down Expand Up @@ -59,10 +64,11 @@ export default function ParallaxScrollView({
>
{headerImage}
</Animated.View>

<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
);
}
};

const styles = StyleSheet.create({
container: {
Expand All @@ -79,3 +85,5 @@ const styles = StyleSheet.create({
overflow: 'hidden',
},
});

export default ParallaxScrollView;
66 changes: 66 additions & 0 deletions docs/UI_COMPLEXITY.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 6 additions & 6 deletions performance-baseline.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"_comment": "Performance baseline. Update with: npm run perf:update-baseline",
"version": "1.0.0",
"timestamp": "2026-05-29T00:00:00.000Z",
"timestamp": "2026-06-06T00:00:00.000Z",
"bundleSize": {
"android_bytes": 2000000,
"ios_bytes": 1900000,
"total_bytes": 3900000
"android_bytes": 4000000,
"ios_bytes": 3800000,
"total_bytes": 7800000
},
"startupTime": {
"p50_ms": 800,
"p95_ms": 1200
"p50_ms": 50,
"p95_ms": 200
},
"apiLatency": {
"p50_ms": 200,
Expand Down
2 changes: 1 addition & 1 deletion performance-budget.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"bundleSize": 2500000,
"bundleSize": 52428800,
"tti": 2000,
"apiResponse": 1000
}
28 changes: 28 additions & 0 deletions reports/regression-report.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"timestamp": "2026-06-06T14:52:14.178Z",
"threshold_pct": 5,
"summary": {
"total": 2,
"passed": 2,
"failed": 0,
"skipped": 0
},
"checks": [
{
"label": "startup.p95_ms",
"baseline": 200,
"current": 22,
"change_pct": -89,
"threshold_pct": 5,
"status": "pass"
},
{
"label": "startup.p50_ms",
"baseline": 50,
"current": 14,
"change_pct": -72,
"threshold_pct": 5,
"status": "pass"
}
]
}
13 changes: 13 additions & 0 deletions reports/startup-benchmark.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"timestamp": "2026-06-06T14:52:13.900Z",
"iterations": 10,
"budget_ms": 2000,
"metrics": {
"min": 13,
"max": 22,
"avg": 15,
"p50": 14,
"p95": 22
},
"passed": true
}
6 changes: 4 additions & 2 deletions src/__tests__/hooks/useAdaptiveFrameRate.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { renderHook } from '@testing-library/react-native';
import * as Battery from 'expo-battery';

import { useAdaptiveFrameRate } from '../../hooks/useAdaptiveFrameRate';

const mockUseLowPowerMode = Battery.useLowPowerMode as jest.Mock;
Expand Down Expand Up @@ -76,11 +77,12 @@ describe('useAdaptiveFrameRate', () => {
expect(result.current.isLowEndDevice).toBe(true);
});

it('stays at 60 fps when RAM is exactly 2 GB (boundary)', () => {
it('reduces to 30 fps when RAM is exactly 2 GB (boundary — mid-range)', () => {
mockDevice().deviceYearClass = 2021;
mockDevice().totalMemory = 2 * GB;
const { result } = renderAdaptiveHook();
expect(result.current.targetFPS).toBe(60);
// 2 GB sits in the mid range (2 GB <= RAM < 4 GB) → shouldReduceAnimations → 30 fps
expect(result.current.targetFPS).toBe(30);
});

it('stays at 60 fps when totalMemory is null', () => {
Expand Down
155 changes: 155 additions & 0 deletions src/__tests__/hooks/useDeviceUiComplexity.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading