Skip to content
Open
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
27 changes: 18 additions & 9 deletions components/parallax-scroll-view.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
Expand All @@ -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;

Expand All @@ -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<Animated.ScrollView>();
const scrollOffset = useScrollOffset(scrollRef);
const { shouldDisableHeavyEffects } = useDeviceUiComplexity();

const headerAnimatedStyle = useAnimatedStyle(() => {
if (shouldDisableHeavyEffects) return {};

return {
transform: [
{
Expand All @@ -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]
),
},
],
};
Expand All @@ -59,10 +65,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 +86,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.
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);
});
});
});
68 changes: 44 additions & 24 deletions src/components/mobile/LessonCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -39,6 +40,7 @@ const LessonCarousel = ({
const flatListRef = useRef<FlatList<Lesson>>(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);
Expand Down Expand Up @@ -101,12 +103,16 @@ const LessonCarousel = ({
<View style={styles.container} testID="LessonCarousel">
<View style={styles.progressBarContainer}>
<Animated.View style={{ width: progressBarWidth, height: '100%' }}>
<LinearGradient
colors={['#20afe7', '#2c8aec', '#586ce9']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.progressBarGradient}
/>
{shouldDisableHeavyEffects ? (
<View style={[styles.progressBarGradient, { backgroundColor: '#20afe7' }]} />
) : (
<LinearGradient
colors={['#20afe7', '#2c8aec', '#586ce9']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.progressBarGradient}
/>
)}
</Animated.View>
</View>

Expand Down Expand Up @@ -191,16 +197,24 @@ const LessonCarousel = ({

{currentIndex === lessons.length - 1 ? (
<TouchableOpacity onPress={onLastLessonNext} style={styles.navButton}>
<LinearGradient
colors={['#20afe7', '#2c8aec', '#586ce9']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.nextButtonGradient}
>
<Text style={styles.nextButtonText}>
{isLastLessonInSection ? 'Continue →' : 'Next →'}
</Text>
</LinearGradient>
{shouldDisableHeavyEffects ? (
<View style={[styles.nextButtonGradient, { backgroundColor: '#20afe7' }]}>
<Text style={styles.nextButtonText}>
{isLastLessonInSection ? 'Continue →' : 'Next →'}
</Text>
</View>
) : (
<LinearGradient
colors={['#20afe7', '#2c8aec', '#586ce9']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.nextButtonGradient}
>
<Text style={styles.nextButtonText}>
{isLastLessonInSection ? 'Continue →' : 'Next →'}
</Text>
</LinearGradient>
)}
</TouchableOpacity>
) : (
<TouchableOpacity
Expand All @@ -213,14 +227,20 @@ const LessonCarousel = ({
}}
style={styles.navButton}
>
<LinearGradient
colors={['#20afe7', '#2c8aec', '#586ce9']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.nextButtonGradient}
>
<Text style={styles.nextButtonText}>Next →</Text>
</LinearGradient>
{shouldDisableHeavyEffects ? (
<View style={[styles.nextButtonGradient, { backgroundColor: '#20afe7' }]}>
<Text style={styles.nextButtonText}>Next →</Text>
</View>
) : (
<LinearGradient
colors={['#20afe7', '#2c8aec', '#586ce9']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.nextButtonGradient}
>
<Text style={styles.nextButtonText}>Next →</Text>
</LinearGradient>
)}
</TouchableOpacity>
)}
</View>
Expand Down
Loading