feat: Add Dynamic Material Theme Colors (Closes #1863)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+23
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user