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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,4 @@ GoogleService-Info.plist
# Documentation files (except README.md)
*.md
!README.md
!docs/**/*.md
3 changes: 2 additions & 1 deletion App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as Font from 'expo-font';
import * as SplashScreen from 'expo-splash-screen';
import { ErrorBoundary } from './src/components/common/ErrorBoundary';
import { initializeLogging } from './src/config/logging';
import { AuthProvider } from './src/hooks';
import { AuthProvider, useAdaptiveTheme } from './src/hooks';
import AppNavigator from './src/navigation/AppNavigator';
import { setupNotificationNavigation } from './src/navigation/linking';
import { apiClient } from './src/services/api';
Expand Down Expand Up @@ -56,6 +56,7 @@ if (__DEV__) {

const App = () => {
const theme = useAppStore((state) => state.theme);
useAdaptiveTheme();

const appStateRef = useRef<AppStateStatus>(AppState.currentState);
const [appIsReady, setAppIsReady] = React.useState(false);
Expand Down
62 changes: 62 additions & 0 deletions docs/adaptive-theme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Adaptive Theme

Adaptive Theme automatically switches the app between light and dark mode based on ambient light detected by the device light sensor (Android).

## Features

### Automatic adjustment

When **Adaptive Theme** is enabled under **Settings → App**, the app reads illuminance (lux) from the device light sensor and updates the global theme in the app store.

### Hysteresis thresholds

To avoid flickering near a single cutoff, two thresholds are used:

| Transition | Condition |
|------------|-----------|
| Light → Dark | Lux falls below **25** |
| Dark → Light | Lux rises above **75** |

Readings between 25 and 75 lux are in the hysteresis band; the current theme is kept.

### Consecutive reading debounce

A theme change requires **3** consecutive readings that agree on the same target theme, with sensor updates spaced **1000 ms** apart (`LightSensor.setUpdateInterval(1000)`).

### Manual override

If the user changes **Theme** in the picker while Adaptive Theme is on, Adaptive Theme is turned off immediately so the explicit choice is respected.

### Background behavior

The light sensor subscription is removed when the app moves to the background and re-established when the app returns to the foreground (if Adaptive Theme is still enabled).

## Implementation

| Piece | Location |
|-------|----------|
| Setting | `src/store/settingsStore.ts` — `adaptiveThemeEnabled` |
| Sensor hook | `src/hooks/useAdaptiveTheme.ts` |
| Root wiring | `App.tsx` |
| UI toggle | `src/components/mobile/MobileSettings.tsx` |
| Dependency | `expo-sensors` (`LightSensor`, Android only) |

Pure debounce/hysteresis logic is exported from the hook for unit tests: `getCandidateThemeFromLux`, `advanceDebounce`.

## How to test

### Automated

```bash
npm run test -- tests/store/settingsStore.test.ts
npm run test -- tests/hooks/useAdaptiveTheme.test.ts
```

### Manual (Android device or emulator with light sensor)

1. Open **Settings → App** and confirm **Adaptive Theme** appears with a switch.
2. Enable **Adaptive Theme** and cover/uncover the sensor or move between bright and dim areas; theme should change only after stable readings (no rapid flicker).
3. With Adaptive Theme on, change **Theme** manually; the Adaptive Theme switch should turn off.
4. Background the app and confirm no crashes; return to foreground and verify adaptive behavior resumes when enabled.

**Note:** `LightSensor` is only available on Android. On iOS and web, enabling Adaptive Theme has no sensor effect.
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-sensors": "^56.0.5",
"expo-speech-recognition": "^3.1.3",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
Expand Down
19 changes: 18 additions & 1 deletion src/components/mobile/MobileSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ export function MobileSettings({
setAutoplay,
hapticFeedback,
setHapticFeedback,
adaptiveThemeEnabled,
setAdaptiveThemeEnabled,
} = useSettingsStore();

const {
Expand Down Expand Up @@ -330,7 +332,22 @@ export function MobileSettings({
label="Theme"
value={theme}
options={THEME_OPTIONS}
onValueChange={setTheme}
onValueChange={(value) => {
setTheme(value as 'light' | 'dark');
setAdaptiveThemeEnabled(false);
}}
/>
}
/>

<SettingRow
icon={<Sun size={18} color="#f59e0b" />}
label="Adaptive Theme"
description="Switch light/dark based on ambient light"
right={
<NativeToggle
value={adaptiveThemeEnabled}
onValueChange={setAdaptiveThemeEnabled}
/>
}
/>
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './useAdaptiveTheme';
export * from './useAnalytics';
export { AuthProvider, useAuth } from './useAuth';
export * from './useBiometricAuth';
Expand Down
128 changes: 128 additions & 0 deletions src/hooks/useAdaptiveTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { LightSensor } from 'expo-sensors';
import { useEffect, useRef } from 'react';
import { AppState, type AppStateStatus } from 'react-native';

import { useAppStore } from '../store';
import { useSettingsStore } from '../store/settingsStore';

export const DARK_LUX_THRESHOLD = 25;
export const LIGHT_LUX_THRESHOLD = 75;
export const CONSECUTIVE_READINGS_REQUIRED = 3;
export const READING_INTERVAL_MS = 1000;

export type CandidateTheme = 'light' | 'dark' | null;

export interface DebounceState {
candidate: CandidateTheme;
consecutiveCount: number;
}

export function getCandidateThemeFromLux(lux: number): CandidateTheme {
if (lux < DARK_LUX_THRESHOLD) return 'dark';
if (lux > LIGHT_LUX_THRESHOLD) return 'light';
return null;
}

export function advanceDebounce(
state: DebounceState,
lux: number,
currentTheme: 'light' | 'dark'
): { state: DebounceState; confirmedTheme: 'light' | 'dark' | null } {
const reading = getCandidateThemeFromLux(lux);

if (reading === null) {
return { state: { candidate: null, consecutiveCount: 0 }, confirmedTheme: null };
}

if (reading === currentTheme) {
return { state: { candidate: null, consecutiveCount: 0 }, confirmedTheme: null };
}

if (reading !== state.candidate) {
return {
state: { candidate: reading, consecutiveCount: 1 },
confirmedTheme: null,
};
}

const nextCount = state.consecutiveCount + 1;
if (nextCount >= CONSECUTIVE_READINGS_REQUIRED) {
return {
state: { candidate: null, consecutiveCount: 0 },
confirmedTheme: reading,
};
}

return {
state: { candidate: reading, consecutiveCount: nextCount },
confirmedTheme: null,
};
}

export function useAdaptiveTheme(): void {
const adaptiveThemeEnabled = useSettingsStore((s) => s.adaptiveThemeEnabled);
const setTheme = useAppStore((s) => s.setTheme);

const debounceRef = useRef<DebounceState>({ candidate: null, consecutiveCount: 0 });
const subscriptionRef = useRef<{ remove: () => void } | null>(null);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);

useEffect(() => {
let cancelled = false;

const removeSubscription = () => {
subscriptionRef.current?.remove();
subscriptionRef.current = null;
debounceRef.current = { candidate: null, consecutiveCount: 0 };
};

const handleReading = (lux: number) => {
const currentTheme = useAppStore.getState().theme;
const { state, confirmedTheme } = advanceDebounce(debounceRef.current, lux, currentTheme);
debounceRef.current = state;
if (confirmedTheme) {
setTheme(confirmedTheme);
}
};

const subscribe = async () => {
const isAvailable = await LightSensor.isAvailableAsync();
if (cancelled || !isAvailable) return;

LightSensor.setUpdateInterval(READING_INTERVAL_MS);
subscriptionRef.current = LightSensor.addListener(({ illuminance }) => {
handleReading(illuminance);
});
};

const shouldSubscribe =
adaptiveThemeEnabled && appStateRef.current === 'active';

if (shouldSubscribe) {
void subscribe();
} else {
removeSubscription();
}

const appStateSubscription = AppState.addEventListener('change', (nextState) => {
const wasBackground = appStateRef.current.match(/inactive|background/);
const isActive = nextState === 'active';
appStateRef.current = nextState;

if (!adaptiveThemeEnabled) return;

if (wasBackground && isActive) {
removeSubscription();
void subscribe();
} else if (nextState.match(/inactive|background/)) {
removeSubscription();
}
});

return () => {
cancelled = true;
appStateSubscription.remove();
removeSubscription();
};
}, [adaptiveThemeEnabled, setTheme]);
}
5 changes: 5 additions & 0 deletions src/store/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface SettingsState {
fontSize: FontSize;
autoplay: boolean;
hapticFeedback: boolean;
adaptiveThemeEnabled: boolean;

// Actions — Account
setProfileVisibility: (v: ProfileVisibility) => void;
Expand All @@ -50,6 +51,7 @@ interface SettingsState {
setFontSize: (v: FontSize) => void;
setAutoplay: (v: boolean) => void;
setHapticFeedback: (v: boolean) => void;
setAdaptiveThemeEnabled: (v: boolean) => void;

// Misc
resetSettings: () => void;
Expand All @@ -69,6 +71,7 @@ const DEFAULT_SETTINGS: Omit<SettingsState, keyof Omit<SettingsState, ProfileVis
fontSize: 'medium' as FontSize,
autoplay: true,
hapticFeedback: true,
adaptiveThemeEnabled: false,
};

const INITIAL_STATE = {
Expand All @@ -85,6 +88,7 @@ const INITIAL_STATE = {
fontSize: 'medium' as FontSize,
autoplay: true,
hapticFeedback: true,
adaptiveThemeEnabled: false,
};

export const useSettingsStore = create<SettingsState>()(
Expand Down Expand Up @@ -112,6 +116,7 @@ export const useSettingsStore = create<SettingsState>()(
setFontSize: (v) => set({ fontSize: v }),
setAutoplay: (v) => set({ autoplay: v }),
setHapticFeedback: (v) => set({ hapticFeedback: v }),
setAdaptiveThemeEnabled: (v) => set({ adaptiveThemeEnabled: v }),

resetSettings: () => set(INITIAL_STATE),
}),
Expand Down
Loading
Loading