From 9debb86bb935464e25f27149427fdff22fe96bbe Mon Sep 17 00:00:00 2001 From: Michelle Nifemi Date: Wed, 27 May 2026 01:03:17 +0100 Subject: [PATCH] Add adaptive theme based on ambient light sensor Automatically switch light/dark mode from Android lux readings with hysteresis and debounced consecutive readings, plus a Settings toggle and manual override when the user picks a theme. Co-authored-by: Cursor --- .gitignore | 1 + App.tsx | 3 +- docs/adaptive-theme.md | 62 ++++++++ package-lock.json | 14 ++ package.json | 1 + src/components/mobile/MobileSettings.tsx | 19 ++- src/hooks/index.ts | 1 + src/hooks/useAdaptiveTheme.ts | 128 +++++++++++++++ src/store/settingsStore.ts | 5 + tests/hooks/useAdaptiveTheme.test.ts | 193 +++++++++++++++++++++++ tests/store/settingsStore.test.ts | 17 ++ 11 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 docs/adaptive-theme.md create mode 100644 src/hooks/useAdaptiveTheme.ts create mode 100644 tests/hooks/useAdaptiveTheme.test.ts diff --git a/.gitignore b/.gitignore index a933881..728ee61 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,4 @@ GoogleService-Info.plist # Documentation files (except README.md) *.md !README.md +!docs/**/*.md diff --git a/App.tsx b/App.tsx index 2a8bdba..79ff64e 100644 --- a/App.tsx +++ b/App.tsx @@ -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'; @@ -56,6 +56,7 @@ if (__DEV__) { const App = () => { const theme = useAppStore((state) => state.theme); + useAdaptiveTheme(); const appStateRef = useRef(AppState.currentState); const [appIsReady, setAppIsReady] = React.useState(false); diff --git a/docs/adaptive-theme.md b/docs/adaptive-theme.md new file mode 100644 index 0000000..d4456d8 --- /dev/null +++ b/docs/adaptive-theme.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 14ad75b..dbd67f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,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", @@ -9124,6 +9125,19 @@ "expo": "*" } }, + "node_modules/expo-sensors": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/expo-sensors/-/expo-sensors-56.0.5.tgz", + "integrity": "sha512-7U9rFWCzh16OG28KvxD+KCNsBJhYEld4V3CT7oZykOPoo8G9+EmOGNFu68cXR7Ctj+uJA4LnHy7OwIOKJoxvLA==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-server": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", diff --git a/package.json b/package.json index bf4aa93..d5e5eb0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/mobile/MobileSettings.tsx b/src/components/mobile/MobileSettings.tsx index b526681..c164d6b 100644 --- a/src/components/mobile/MobileSettings.tsx +++ b/src/components/mobile/MobileSettings.tsx @@ -174,6 +174,8 @@ export function MobileSettings({ setAutoplay, hapticFeedback, setHapticFeedback, + adaptiveThemeEnabled, + setAdaptiveThemeEnabled, } = useSettingsStore(); const { @@ -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); + }} + /> + } + /> + + } + label="Adaptive Theme" + description="Switch light/dark based on ambient light" + right={ + } /> diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9a344e3..5660c78 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useAdaptiveTheme'; export * from './useAnalytics'; export { AuthProvider, useAuth } from './useAuth'; export * from './useBiometricAuth'; diff --git a/src/hooks/useAdaptiveTheme.ts b/src/hooks/useAdaptiveTheme.ts new file mode 100644 index 0000000..f77908f --- /dev/null +++ b/src/hooks/useAdaptiveTheme.ts @@ -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({ candidate: null, consecutiveCount: 0 }); + const subscriptionRef = useRef<{ remove: () => void } | null>(null); + const appStateRef = useRef(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]); +} diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index 252f9f6..e765d3c 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -29,6 +29,7 @@ interface SettingsState { fontSize: FontSize; autoplay: boolean; hapticFeedback: boolean; + adaptiveThemeEnabled: boolean; // Actions — Account setProfileVisibility: (v: ProfileVisibility) => void; @@ -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; @@ -69,6 +71,7 @@ const DEFAULT_SETTINGS: Omit()( @@ -112,6 +116,7 @@ export const useSettingsStore = create()( setFontSize: (v) => set({ fontSize: v }), setAutoplay: (v) => set({ autoplay: v }), setHapticFeedback: (v) => set({ hapticFeedback: v }), + setAdaptiveThemeEnabled: (v) => set({ adaptiveThemeEnabled: v }), resetSettings: () => set(INITIAL_STATE), }), diff --git a/tests/hooks/useAdaptiveTheme.test.ts b/tests/hooks/useAdaptiveTheme.test.ts new file mode 100644 index 0000000..d45db0f --- /dev/null +++ b/tests/hooks/useAdaptiveTheme.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react-native'; +import { AppState } from 'react-native'; + +import { + advanceDebounce, + CONSECUTIVE_READINGS_REQUIRED, + getCandidateThemeFromLux, + useAdaptiveTheme, +} from '../../src/hooks/useAdaptiveTheme'; +import { useAppStore } from '../../src/store'; +import { useSettingsStore } from '../../src/store/settingsStore'; + +const mockRemove = jest.fn(); +const mockAddListener = jest.fn(); +const mockIsAvailableAsync = jest.fn<() => Promise>(); +const mockSetUpdateInterval = jest.fn(); + +jest.mock('expo-sensors', () => ({ + LightSensor: { + isAvailableAsync: () => mockIsAvailableAsync(), + setUpdateInterval: (ms: number) => mockSetUpdateInterval(ms), + addListener: (listener: (data: { illuminance: number }) => void) => { + mockAddListener(listener); + return { remove: mockRemove }; + }, + }, +})); + +type AppStateChangeHandler = (state: string) => void; +let appStateHandler: AppStateChangeHandler | null = null; + +jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, handler) => { + appStateHandler = handler as AppStateChangeHandler; + return { remove: jest.fn() }; +}); + +beforeEach(() => { + mockRemove.mockClear(); + mockAddListener.mockClear(); + mockSetUpdateInterval.mockClear(); + mockIsAvailableAsync.mockResolvedValue(true); + appStateHandler = null; + + useSettingsStore.setState({ adaptiveThemeEnabled: false }); + useAppStore.setState({ theme: 'light' }); +}); + +describe('getCandidateThemeFromLux', () => { + it('returns dark below dark threshold', () => { + expect(getCandidateThemeFromLux(10)).toBe('dark'); + expect(getCandidateThemeFromLux(24)).toBe('dark'); + }); + + it('returns light above light threshold', () => { + expect(getCandidateThemeFromLux(76)).toBe('light'); + expect(getCandidateThemeFromLux(200)).toBe('light'); + }); + + it('returns null in hysteresis band', () => { + expect(getCandidateThemeFromLux(25)).toBeNull(); + expect(getCandidateThemeFromLux(50)).toBeNull(); + expect(getCandidateThemeFromLux(75)).toBeNull(); + }); +}); + +describe('advanceDebounce', () => { + const initial = { candidate: null as const, consecutiveCount: 0 }; + + it('requires consecutive stable readings before confirming', () => { + let state = initial; + let result = advanceDebounce(state, 10, 'light'); + expect(result.confirmedTheme).toBeNull(); + expect(result.state.consecutiveCount).toBe(1); + + state = result.state; + result = advanceDebounce(state, 10, 'light'); + expect(result.confirmedTheme).toBeNull(); + expect(result.state.consecutiveCount).toBe(2); + + state = result.state; + result = advanceDebounce(state, 10, 'light'); + expect(result.confirmedTheme).toBe('dark'); + expect(result.state.consecutiveCount).toBe(0); + }); + + it('resets debounce when a reading matches the current theme', () => { + const building = advanceDebounce(initial, 10, 'light'); + expect(building.state.candidate).toBe('dark'); + + const cleared = advanceDebounce(building.state, 100, 'light'); + expect(cleared.state.candidate).toBeNull(); + expect(cleared.state.consecutiveCount).toBe(0); + expect(cleared.confirmedTheme).toBeNull(); + }); + + it('clears candidate in hysteresis band', () => { + const built = advanceDebounce(initial, 10, 'light'); + const inBand = advanceDebounce(built.state, 50, 'light'); + expect(inBand.state.candidate).toBeNull(); + expect(inBand.state.consecutiveCount).toBe(0); + }); + + it('uses CONSECUTIVE_READINGS_REQUIRED for confirmation', () => { + expect(CONSECUTIVE_READINGS_REQUIRED).toBe(3); + }); +}); + +describe('useAdaptiveTheme', () => { + it('does not subscribe when adaptive theme is disabled', async () => { + useSettingsStore.setState({ adaptiveThemeEnabled: false }); + + renderHook(() => useAdaptiveTheme()); + await act(async () => {}); + + expect(mockIsAvailableAsync).not.toHaveBeenCalled(); + expect(mockAddListener).not.toHaveBeenCalled(); + }); + + it('subscribes when enabled and sensor is available', async () => { + useSettingsStore.setState({ adaptiveThemeEnabled: true }); + + renderHook(() => useAdaptiveTheme()); + await act(async () => { + await Promise.resolve(); + }); + + expect(mockIsAvailableAsync).toHaveBeenCalled(); + expect(mockSetUpdateInterval).toHaveBeenCalledWith(1000); + expect(mockAddListener).toHaveBeenCalled(); + }); + + it('does not change theme on a single reading (debounce)', async () => { + useSettingsStore.setState({ adaptiveThemeEnabled: true }); + useAppStore.setState({ theme: 'light' }); + + renderHook(() => useAdaptiveTheme()); + await act(async () => { + await Promise.resolve(); + }); + + const listener = mockAddListener.mock.calls[0][0] as (data: { + illuminance: number; + }) => void; + + act(() => listener({ illuminance: 10 })); + expect(useAppStore.getState().theme).toBe('light'); + }); + + it('applies theme after debounced consecutive dark readings', async () => { + useSettingsStore.setState({ adaptiveThemeEnabled: true }); + useAppStore.setState({ theme: 'light' }); + + renderHook(() => useAdaptiveTheme()); + await act(async () => { + await Promise.resolve(); + }); + + const listener = mockAddListener.mock.calls[0][0] as (data: { + illuminance: number; + }) => void; + + act(() => listener({ illuminance: 10 })); + act(() => listener({ illuminance: 10 })); + act(() => listener({ illuminance: 10 })); + + expect(useAppStore.getState().theme).toBe('dark'); + }); + + it('removes subscription on unmount', async () => { + useSettingsStore.setState({ adaptiveThemeEnabled: true }); + + const { unmount } = renderHook(() => useAdaptiveTheme()); + await act(async () => { + await Promise.resolve(); + }); + + unmount(); + expect(mockRemove).toHaveBeenCalled(); + }); + + it('unsubscribes when app goes to background', async () => { + useSettingsStore.setState({ adaptiveThemeEnabled: true }); + + renderHook(() => useAdaptiveTheme()); + await act(async () => { + await Promise.resolve(); + }); + + act(() => appStateHandler?.('background')); + expect(mockRemove).toHaveBeenCalled(); + }); +}); diff --git a/tests/store/settingsStore.test.ts b/tests/store/settingsStore.test.ts index e5d4e0b..86b5b7b 100644 --- a/tests/store/settingsStore.test.ts +++ b/tests/store/settingsStore.test.ts @@ -16,6 +16,7 @@ const INITIAL_STATE = { fontSize: 'medium' as const, autoplay: true, hapticFeedback: true, + adaptiveThemeEnabled: false, }; describe('useSettingsStore', () => { @@ -188,6 +189,19 @@ describe('useSettingsStore', () => { }); }); + describe('setAdaptiveThemeEnabled', () => { + it('enables adaptive theme', () => { + useSettingsStore.getState().setAdaptiveThemeEnabled(true); + expect(useSettingsStore.getState().adaptiveThemeEnabled).toBe(true); + }); + + it('disables adaptive theme', () => { + useSettingsStore.setState({ adaptiveThemeEnabled: true }); + useSettingsStore.getState().setAdaptiveThemeEnabled(false); + expect(useSettingsStore.getState().adaptiveThemeEnabled).toBe(false); + }); + }); + // ── resetSettings ───────────────────────────────────────────────────────── describe('resetSettings', () => { @@ -207,6 +221,7 @@ describe('useSettingsStore', () => { fontSize: 'large', autoplay: false, hapticFeedback: false, + adaptiveThemeEnabled: true, }); useSettingsStore.getState().resetSettings(); @@ -225,6 +240,7 @@ describe('useSettingsStore', () => { expect(state.fontSize).toBe('medium'); expect(state.autoplay).toBe(true); expect(state.hapticFeedback).toBe(true); + expect(state.adaptiveThemeEnabled).toBe(false); }); it('is idempotent — calling reset twice yields defaults', () => { @@ -245,6 +261,7 @@ describe('useSettingsStore', () => { expect(state.fontSize).toBe('medium'); expect(state.downloadQuality).toBe('medium'); expect(state.hapticFeedback).toBe(true); + expect(state.adaptiveThemeEnabled).toBe(false); }); }); });