feat: Add Dynamic Material Theme Colors (Closes #1863)

This commit is contained in:
Rajarshee Chatterjee
2026-07-23 09:01:41 +05:30
parent 4a4208e336
commit 44c8e54ed8
9 changed files with 182 additions and 41 deletions
+26 -5
View File
@@ -1,11 +1,15 @@
import 'react-native-url-polyfill/auto';
import { enableFreeze } from 'react-native-screens';
import { Suspense, useEffect } from 'react';
import { PropsWithChildren, Suspense, useEffect, useMemo } from 'react';
import { StatusBar, StyleSheet } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import * as SplashScreen from 'expo-splash-screen';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Provider as PaperProvider } from 'react-native-paper';
import {
MD3DarkTheme,
MD3LightTheme,
Provider as PaperProvider,
} from 'react-native-paper';
import AppErrorBoundary, {
ErrorFallback,
@@ -15,10 +19,27 @@ import Main from './src/navigators/Main';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import { useInitDatabase } from '@database/db';
import { useInitializeAppServices } from '@hooks/common/useInitializeAppServices';
import { ThemeProvider } from '@hooks/persisted/useTheme';
import { ThemeProvider, useTheme } from '@hooks/persisted/useTheme';
enableFreeze(true);
const ThemedPaperProvider = ({ children }: PropsWithChildren) => {
const theme = useTheme();
const paperTheme = useMemo(() => {
const baseTheme = theme.isDark ? MD3DarkTheme : MD3LightTheme;
return {
...baseTheme,
colors: {
...baseTheme.colors,
...theme,
},
};
}, [theme]);
return <PaperProvider theme={paperTheme}>{children}</PaperProvider>;
};
const App = () => {
const { success: databaseReady, error: databaseError } = useInitDatabase();
const { ready: servicesReady, error: servicesError } =
@@ -50,12 +71,12 @@ const App = () => {
<ThemeProvider>
<AppErrorBoundary>
<SafeAreaProvider>
<PaperProvider>
<ThemedPaperProvider>
<BottomSheetModalProvider>
<StatusBar translucent={true} backgroundColor="transparent" />
<Main />
</BottomSheetModalProvider>
</PaperProvider>
</ThemedPaperProvider>
</SafeAreaProvider>
</AppErrorBoundary>
</ThemeProvider>
+1
View File
@@ -38,6 +38,7 @@
"@legendapp/list": "^2.0.19",
"@noble/ciphers": "^2.2.0",
"@op-engineering/op-sqlite": "^15.2.14",
"@pchmn/expo-material3-theme": "^1.4.0",
"@preeternal/react-native-cookie-manager": "^6.3.3",
"@react-native-community/slider": "^5.2.0",
"@react-native-documents/picker": "^12.0.1",
+23
View File
@@ -29,6 +29,9 @@ importers:
'@op-engineering/op-sqlite':
specifier: ^15.2.14
version: 15.2.14(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
'@pchmn/expo-material3-theme':
specifier: ^1.4.0
version: 1.4.0(expo@57.0.7)(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
'@preeternal/react-native-cookie-manager':
specifier: ^6.3.3
version: 6.3.3(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)
@@ -1543,6 +1546,9 @@ packages:
react: '*'
react-native: '*'
'@material/material-color-utilities@0.3.0':
resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==}
'@napi-rs/wasm-runtime@1.1.6':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
peerDependencies:
@@ -1567,6 +1573,13 @@ packages:
'@sqlite.org/sqlite-wasm':
optional: true
'@pchmn/expo-material3-theme@1.4.0':
resolution: {integrity: sha512-XNt0gpZX5argOQ7JiFnNdiXHpHjmpwJoMQrlnd1kpb8ly/IWZN2hh011incmfZXV2Xs+p6Uo1BGDKv8hW4MQTQ==}
peerDependencies:
expo: '*'
react: '*'
react-native: '*'
'@preeternal/react-native-cookie-manager@6.3.3':
resolution: {integrity: sha512-ddA3H7nbV0n/EdJ3ZXDd9GpT9jzhBiMrVeXftJf1mJ1gqyQnZ6d8EEd+k/lnjzfhqK6Z2kvXggCSpwAH991Gvw==}
peerDependencies:
@@ -7600,6 +7613,8 @@ snapshots:
react-native: 0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0)
use-sync-external-store: 1.6.0(react@19.2.3)
'@material/material-color-utilities@0.3.0': {}
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@@ -7616,6 +7631,14 @@ snapshots:
react: 19.2.3
react-native: 0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0)
'@pchmn/expo-material3-theme@1.4.0(expo@57.0.7)(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)':
dependencies:
'@material/material-color-utilities': 0.3.0
color: 4.2.3
expo: 57.0.7(09a92d6f4cb27d968ea2a7d0b76b3213)
react: 19.2.3
react-native: 0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0)
'@preeternal/react-native-cookie-manager@6.3.3(react-native@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(@react-native/jest-preset@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(react@19.2.3)(supports-color@9.4.0))(@react-native/metro-config@0.86.0(@babel/core@7.29.7(supports-color@9.4.0))(supports-color@9.4.0))(@types/react@19.2.17)(react@19.2.3)(supports-color@9.4.0))(react@19.2.3)':
dependencies:
react: 19.2.3
+47 -10
View File
@@ -7,17 +7,24 @@ import {
useState,
PropsWithChildren,
} from 'react';
import { Appearance, ColorSchemeName } from 'react-native';
import { Appearance, AppState, ColorSchemeName } from 'react-native';
import {
useMMKVBoolean,
useMMKVNumber,
useMMKVString,
} from 'react-native-mmkv';
import type { Material3Theme } from '@pchmn/expo-material3-theme';
import { overlay } from 'react-native-paper';
import Color from 'color';
import { ThemeColors } from '@theme/types';
import { darkThemes, lightThemes } from '@theme/md3';
import {
DYNAMIC_THEME_ID,
getSystemDynamicTheme,
isDynamicThemeAvailable,
toDynamicThemeColors,
} from '@theme/dynamic';
const getElevationColor = (colors: ThemeColors, elevation: number): string => {
return Color(colors.surface)
@@ -113,13 +120,20 @@ const getBaseTheme = (
themeMode: string,
themeId: number | undefined,
systemColorScheme: ColorSchemeName,
dynamicTheme: Material3Theme,
): ThemeColors => {
if (themeMode === 'system') {
const shouldUseDarkTheme = systemColorScheme === 'dark';
return findThemeById(themeId, shouldUseDarkTheme);
const isDark =
themeMode === 'system'
? systemColorScheme === 'dark'
: themeMode === 'dark';
if (themeId === DYNAMIC_THEME_ID && isDynamicThemeAvailable) {
return toDynamicThemeColors(dynamicTheme, isDark);
}
const isDark = themeMode === 'dark';
if (themeMode === 'system') {
return findThemeById(themeId, isDark);
}
return findThemeById(themeId, isDark);
};
@@ -135,23 +149,46 @@ export const ThemeProvider = ({ children }: PropsWithChildren) => {
const [systemColorScheme, setSystemColorScheme] = useState<ColorSchemeName>(
Appearance.getColorScheme() ?? 'unspecified',
);
const [dynamicTheme, setDynamicTheme] = useState(getSystemDynamicTheme);
useEffect(() => {
const subscription = Appearance.addChangeListener(({ colorScheme }) => {
setSystemColorScheme(colorScheme);
const appearanceSubscription = Appearance.addChangeListener(
({ colorScheme }) => {
setSystemColorScheme(colorScheme);
},
);
const appStateSubscription = AppState.addEventListener('change', state => {
if (state === 'active' && isDynamicThemeAvailable) {
setDynamicTheme(getSystemDynamicTheme());
}
});
return () => subscription.remove();
return () => {
appearanceSubscription.remove();
appStateSubscription.remove();
};
}, []);
const theme = useMemo<ThemeColors>(() => {
const baseTheme = getBaseTheme(themeMode, themeId, systemColorScheme);
const baseTheme = getBaseTheme(
themeMode,
themeId,
systemColorScheme,
dynamicTheme,
);
const withAmoled = applyAmoledBlack(baseTheme, isAmoledBlack);
const withAccent = applyCustomAccent(withAmoled, customAccent);
const finalTheme = addComputedColors(withAccent);
return finalTheme;
}, [themeId, themeMode, systemColorScheme, isAmoledBlack, customAccent]);
}, [
themeId,
themeMode,
systemColorScheme,
dynamicTheme,
isAmoledBlack,
customAccent,
]);
return createElement(ThemeContext.Provider, { value: theme }, children);
};
+15 -2
View File
@@ -17,6 +17,11 @@ import { ThemePicker } from '@components/ThemePicker/ThemePicker';
import { ThemeColors } from '@theme/types';
import { useTheme } from '@hooks/persisted';
import { darkThemes, lightThemes } from '@theme/md3';
import {
getSystemDynamicTheme,
isDynamicThemeAvailable,
toDynamicThemeColors,
} from '@theme/dynamic';
import { getString } from '@strings/translations';
import { LegendList } from '@legendapp/list';
import Switch from '@components/Switch/Switch';
@@ -60,8 +65,16 @@ export default function ThemeSelectionStep() {
const currentMode = themeMode as ThemeMode;
const availableThemes = useMemo(() => {
return theme.isDark ? darkThemes : lightThemes;
}, [theme.isDark]);
const themes = theme.isDark ? darkThemes : lightThemes;
if (!isDynamicThemeAvailable) {
return themes;
}
return [
toDynamicThemeColors(getSystemDynamicTheme(), theme.isDark),
...themes,
];
}, [theme]);
const themeModeOptions: SegmentedControlOption<ThemeMode>[] = useMemo(
() => [
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import {
ScrollView,
StyleSheet,
@@ -23,6 +23,12 @@ import { Appbar, List, SafeAreaView, SegmentedControl } from '@components';
import { AppearanceSettingsScreenProps } from '@navigators/types';
import { getString } from '@strings/translations';
import { darkThemes, lightThemes } from '@theme/md3';
import {
DYNAMIC_THEME_ID,
getSystemDynamicTheme,
isDynamicThemeAvailable,
toDynamicThemeColors,
} from '@theme/dynamic';
import { ThemeColors } from '@theme/types';
import switchTheme from 'react-native-theme-switch-animation';
import Color from 'color';
@@ -56,6 +62,21 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => {
: colorScheme === 'unspecified'
? 'light'
: colorScheme;
const availableThemes = useMemo(() => {
const themes = actualThemeMode === 'light' ? lightThemes : darkThemes;
if (!isDynamicThemeAvailable) {
return themes;
}
const dynamicTheme =
theme.id === DYNAMIC_THEME_ID
? theme
: toDynamicThemeColors(
getSystemDynamicTheme(),
actualThemeMode === 'dark',
);
return [dynamicTheme, ...themes];
}, [actualThemeMode, theme]);
/**
* Accent Color Modal
@@ -178,6 +199,7 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => {
event: GestureResponderEvent,
) => {
setThemeId(selectedTheme.id);
setCustomAccentColor(undefined);
event.currentTarget.measure((_x1, _y1, width, height, px, py) => {
switchTheme({
switchThemeFunction: () => {},
@@ -219,10 +241,6 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => {
/>
</View>
{/* Light Themes */}
{/*<Text style={[{ color: theme.onSurface }, styles.themeSectionText]}>
{getString('appearanceScreen.lightTheme')}
</Text>*/}
<View style={styles.scrollViewContainer}>
<ScrollView
contentContainerStyle={[
@@ -232,17 +250,15 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => {
horizontal={true}
showsHorizontalScrollIndicator={false}
>
{(actualThemeMode === 'light' ? lightThemes : darkThemes).map(
item => (
<ThemePicker
horizontal
key={item.id}
currentTheme={theme}
theme={item}
onPress={e => handleThemeSelect(item, e)}
/>
),
)}
{availableThemes.map(item => (
<ThemePicker
horizontal
key={item.id}
currentTheme={theme}
theme={item}
onPress={e => handleThemeSelect(item, e)}
/>
))}
</ScrollView>
</View>
{theme.isDark ? (
@@ -253,12 +269,14 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => {
theme={theme}
/>
) : null}
<List.ColorItem
title={getString('appearanceScreen.accentColor')}
color={Color(theme.primary)}
onPress={showAccentColorModal}
theme={theme}
/>
{theme.id === DYNAMIC_THEME_ID ? null : (
<List.ColorItem
title={getString('appearanceScreen.accentColor')}
color={Color(theme.primary)}
onPress={showAccentColorModal}
theme={theme}
/>
)}
<List.Item
title={getString('appearanceScreen.appLanguage')}
description={getCurrentLanguageName()}
+26
View File
@@ -0,0 +1,26 @@
import {
getMaterial3Theme,
isDynamicThemeSupported,
type Material3Theme,
} from '@pchmn/expo-material3-theme';
import { getString } from '@strings/translations';
import type { ThemeColors } from '@theme/types';
export const DYNAMIC_THEME_ID = -1;
const DYNAMIC_THEME_FALLBACK_COLOR = '#0057CE';
export const isDynamicThemeAvailable = isDynamicThemeSupported;
export const getSystemDynamicTheme = (): Material3Theme =>
getMaterial3Theme(DYNAMIC_THEME_FALLBACK_COLOR);
export const toDynamicThemeColors = (
dynamicTheme: Material3Theme,
isDark: boolean,
): ThemeColors => ({
...(isDark ? dynamicTheme.dark : dynamicTheme.light),
id: DYNAMIC_THEME_ID,
name: getString('appearanceScreen.dynamicColors'),
isDark,
});
+3 -2
View File
@@ -46,6 +46,7 @@
"appLanguageDefault": "Default",
"appTheme": "App theme",
"darkTheme": "Dark Theme",
"dynamicColors": "Dynamic",
"hideBackdrop": "Hide backdrop",
"lightTheme": "Light Theme",
"navbar": "Navbar",
@@ -549,8 +550,8 @@
"topNovelsByTimeSpent": "Top novels by time spent",
"topCategoriesByTimeSpent": "Top categories by time spent",
"totalTimeSpent": "Total time spent",
"showNovels": "Group by novel",
"showCategories": "Group by category"
"showNovels": "Group by novel",
"showCategories": "Group by category"
},
"tracking": "Tracking",
"trackingScreen": {
+1
View File
@@ -43,6 +43,7 @@ export interface StringMap {
'appearanceScreen.appLanguageDefault': 'string';
'appearanceScreen.appTheme': 'string';
'appearanceScreen.darkTheme': 'string';
'appearanceScreen.dynamicColors': 'string';
'appearanceScreen.hideBackdrop': 'string';
'appearanceScreen.lightTheme': 'string';
'appearanceScreen.navbar': 'string';