From 44c8e54ed80bb47cc49c2461adca66ced94b2d1c Mon Sep 17 00:00:00 2001 From: Rajarshee Chatterjee Date: Thu, 23 Jul 2026 09:01:41 +0530 Subject: [PATCH] feat: Add Dynamic Material Theme Colors (Closes #1863) --- App.tsx | 31 ++++++++-- package.json | 1 + pnpm-lock.yaml | 23 +++++++ src/hooks/persisted/useTheme.ts | 57 ++++++++++++++--- src/screens/onboarding/ThemeSelectionStep.tsx | 17 ++++- .../SettingsAppearanceScreen.tsx | 62 ++++++++++++------- src/theme/dynamic.ts | 26 ++++++++ strings/languages/en/strings.json | 5 +- strings/types/index.ts | 1 + 9 files changed, 182 insertions(+), 41 deletions(-) create mode 100644 src/theme/dynamic.ts diff --git a/App.tsx b/App.tsx index e168db79..4ed26330 100644 --- a/App.tsx +++ b/App.tsx @@ -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 {children}; +}; + const App = () => { const { success: databaseReady, error: databaseError } = useInitDatabase(); const { ready: servicesReady, error: servicesError } = @@ -50,12 +71,12 @@ const App = () => { - +
- + diff --git a/package.json b/package.json index 7fea21c0..40855596 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4863ffee..d2b100c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/hooks/persisted/useTheme.ts b/src/hooks/persisted/useTheme.ts index 26d24e31..816e758c 100644 --- a/src/hooks/persisted/useTheme.ts +++ b/src/hooks/persisted/useTheme.ts @@ -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( 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(() => { - 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); }; diff --git a/src/screens/onboarding/ThemeSelectionStep.tsx b/src/screens/onboarding/ThemeSelectionStep.tsx index 88d171c5..c81e3830 100644 --- a/src/screens/onboarding/ThemeSelectionStep.tsx +++ b/src/screens/onboarding/ThemeSelectionStep.tsx @@ -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[] = useMemo( () => [ diff --git a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx index e187882b..3d0cf16a 100644 --- a/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx +++ b/src/screens/settings/SettingsAppearanceScreen/SettingsAppearanceScreen.tsx @@ -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) => { /> - {/* Light Themes */} - {/* - {getString('appearanceScreen.lightTheme')} - */} { horizontal={true} showsHorizontalScrollIndicator={false} > - {(actualThemeMode === 'light' ? lightThemes : darkThemes).map( - item => ( - handleThemeSelect(item, e)} - /> - ), - )} + {availableThemes.map(item => ( + handleThemeSelect(item, e)} + /> + ))} {theme.isDark ? ( @@ -253,12 +269,14 @@ const AppearanceSettings = ({ navigation }: AppearanceSettingsScreenProps) => { theme={theme} /> ) : null} - + {theme.id === DYNAMIC_THEME_ID ? null : ( + + )} + 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, +}); diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json index 18a01e38..858153cf 100644 --- a/strings/languages/en/strings.json +++ b/strings/languages/en/strings.json @@ -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": { diff --git a/strings/types/index.ts b/strings/types/index.ts index 960ab38a..f050f5e8 100644 --- a/strings/types/index.ts +++ b/strings/types/index.ts @@ -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';