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';